Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 3 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,10 @@ jobs:
- name: Checkout Code
uses: actions/checkout@v2

- name: switch to gcc-10 on linux
run: |
sudo update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-10 100 --slave /usr/bin/g++ g++ /usr/bin/g++-10 --slave /usr/bin/gcov gcov /usr/bin/gcov-10
sudo update-alternatives --set gcc /usr/bin/gcc-10

- name: Configure (${{ matrix.configuration }})
run: cmake -S . -Bbuild -DCMAKE_BUILD_TYPE=${{ matrix.configuration }}
run: >
cmake -S . -Bbuild -DCMAKE_BUILD_TYPE=${{ matrix.configuration }}
-DSS_TESTS=ON

- name: Build with ${{ matrix.compiler }}
run: cmake --build build --config ${{ matrix.configuration }}
Expand Down
6 changes: 6 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,12 @@ if(SS_DEVELOPMENT_BUILD)
endif(SS_DEVELOPMENT_BUILD)

if(SS_TESTS)
# enable_testing() must be called in the TOP-LEVEL list file or no
# root CTestTestfile.cmake is generated, and `ctest` in the build
# directory reports "No tests were found" however many tests the
# subdirectory registers. tests/CMakeLists.txt calls include(CTest)
# for itself, which is not enough on its own.
enable_testing()
add_subdirectory(tests)
endif(SS_TESTS)

Expand Down
31 changes: 24 additions & 7 deletions tests/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -1,16 +1,26 @@
cmake_minimum_required(VERSION 3.14)

set(CMAKE_MODULE_PATH ${CMAKE_MODULE_PATH} "${CMAKE_SOURCE_DIR}/cmake/")
# Doctest.cmake lives in tests/cmake/, not in a top-level cmake/ dir
# (which does not exist), so CMAKE_SOURCE_DIR pointed at nothing and
# include(Doctest) failed. This suite therefore never configured while
# SS_TESTS defaulted to OFF and nobody turned it on.
set(CMAKE_MODULE_PATH ${CMAKE_MODULE_PATH} "${CMAKE_CURRENT_SOURCE_DIR}/cmake/")

include(CTest)

# Doctest.cmake only fetches doctest inside `if(ENABLE_DOCTESTS)`, and
# nothing ever set that variable — so the module was a no-op and the
# tests failed to compile on a missing doctest/doctest.h. This file is
# only added to the build when SS_TESTS is ON, so enabling it here is
# equivalent to "the user asked for tests".
set(ENABLE_DOCTESTS ON)
include(Doctest)

# List all files containing tests. (Change as needed)
set(TESTFILES # All .cpp files in tests/
main.cpp
mergeechotest.cc
debugmesasgetest.cc
parsearcomandtest.cc
bleach_tests.cc
state_serialization_tests.cc
)

set(TEST_MAIN unit_tests) # Default name for test executable (change if you wish).
Expand All @@ -22,7 +32,12 @@ set(TEST_RUNNER_PARAMS "-s") # Any arguemnts to feed the test runner (change as
add_executable(${TEST_MAIN} ${TESTFILES})
target_link_libraries(${TEST_MAIN} PRIVATE ${LIBRARY_NAME} doctest)
set_target_properties(${TEST_MAIN} PROPERTIES RUNTIME_OUTPUT_DIRECTORY ${PROJECT_BINARY_DIR})
target_set_warnings(${TEST_MAIN} ENABLE ALL AS_ERROR ALL DISABLE Annoying) # Set warnings (if needed).
# target_set_warnings() comes from a Warnings.cmake module that was
# never vendored into this repository, so calling it unconditionally is
# a hard configure error. Guarded so the suite builds either way.
if(COMMAND target_set_warnings)
target_set_warnings(${TEST_MAIN} ENABLE ALL AS_ERROR ALL DISABLE Annoying)
endif()

set_target_properties(${TEST_MAIN} PROPERTIES
CXX_STANDARD 20
Expand All @@ -35,5 +50,7 @@ add_test(
NAME ${LIBRARY_NAME}.${TEST_MAIN}
COMMAND ${TEST_MAIN} ${TEST_RUNNER_PARAMS})

# Adds a 'coverage' target.
include(CodeCoverage)
# Adds a 'coverage' target. CodeCoverage.cmake was never vendored into
# this repository either, so this is OPTIONAL: absent it, the suite still
# builds and runs, only the 'coverage' target is unavailable.
include(CodeCoverage OPTIONAL RESULT_VARIABLE SS_CODECOVERAGE_FOUND)
183 changes: 183 additions & 0 deletions tests/bleach_tests.cc
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
/*
* Shared State
*
* Copyright (C) 2026 Asociación Civil Altermundi <info@altermundi.net>
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the
* Free Software Foundation, version 3.
*
* 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 Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>
*
* SPDX-License-Identifier: AGPL-3.0-only
*/

/* Expiry semantics.
*
* `bleach()` decides when data leaves the network, so its edge cases are
* the difference between an entry disappearing a second early and one
* that never leaves. It is an ordinary function — no coroutine, no
* socket — which makes it exactly the kind of thing worth testing in
* process rather than by driving daemons. */

#include "doctest/doctest.h"

#include "sharedstate.hh"
#include "shared_state_errors.hh"

#include "io_context.hh"

#include <chrono>
#include <map>
#include <memory>
#include <string>
#include <system_error>

namespace
{

/** `mStates` is protected, so a small subclass is the supported way to
* put a known state in front of the real implementation. Nothing here
* reimplements behaviour — the code under test is the real `bleach`. */
struct TestableSharedState : SharedState
{
using SharedState::SharedState;

void put(const std::string& type, const std::string& key, int64_t ttl)
{
StateEntry e;
e.mAuthor = "test";
e.mTtl = std::chrono::seconds(ttl);
e.mData.Parse(R"({"v":1})");
auto& entries = mStates[type];
entries.erase(key); // StateEntry is not assignable
entries.emplace(key, e);
}

bool has(const std::string& type, const std::string& key) const
{
auto it = mStates.find(type);
return it != mStates.end() && it->second.count(key) > 0;
}

int64_t ttl(const std::string& type, const std::string& key) const
{
return mStates.at(type).at(key).mTtl.count();
}

std::size_t size(const std::string& type) const
{
auto it = mStates.find(type);
return it == mStates.end() ? 0 : it->second.size();
}

void ensureType(const std::string& type) { mStates[type]; }
};

/** SharedState needs an IOContext reference. bleach() never uses it, but
* forming a reference that does not designate an object is undefined
* behaviour whether or not anything reads it, so make a real one — an
* epoll descriptor is cheap. */
struct Fixture
{
std::unique_ptr<IOContext> io = IOContext::setup();
TestableSharedState state;

Fixture(): state(*io) { REQUIRE(io != nullptr); }
};

constexpr const char* TYPE = "unit_type";

} // namespace

TEST_CASE("bleach removes entries at or below the elapsed time")
{
Fixture f;
auto& st = f.state;
st.put(TYPE, "expires-exactly", 5);
st.put(TYPE, "expires-under", 3);
st.put(TYPE, "survives", 6);

const ssize_t removed = st.bleach(TYPE, std::chrono::seconds(5));

CHECK(removed == 2);
CHECK_FALSE(st.has(TYPE, "expires-exactly")); // <= elapsed, so it goes
CHECK_FALSE(st.has(TYPE, "expires-under"));
CHECK(st.has(TYPE, "survives"));
}

TEST_CASE("bleach decrements the survivors by exactly the elapsed time")
{
Fixture f;
auto& st = f.state;
st.put(TYPE, "a", 100);
st.put(TYPE, "b", 10);

st.bleach(TYPE, std::chrono::seconds(4));

CHECK(st.ttl(TYPE, "a") == 96);
CHECK(st.ttl(TYPE, "b") == 6);
}

TEST_CASE("repeated bleaching is equivalent to one longer bleach")
{
/* The daemon bleaches on a timer and compensates for a late tick by
* passing the real elapsed time, so these two paths must agree or
* entries live longer on a busy node than on an idle one. */
Fixture fs, fo;
auto& stepwise = fs.state;
auto& atOnce = fo.state;
for(auto* st : {&stepwise, &atOnce}) st->put(TYPE, "k", 30);

for(int i = 0; i < 5; ++i) stepwise.bleach(TYPE, std::chrono::seconds(2));
atOnce.bleach(TYPE, std::chrono::seconds(10));

CHECK(stepwise.ttl(TYPE, "k") == atOnce.ttl(TYPE, "k"));
}

TEST_CASE("bleach rejects a non-positive interval instead of corrupting TTLs")
{
Fixture f;
auto& st = f.state;
st.put(TYPE, "k", 10);

std::error_condition ec;
const ssize_t ret = st.bleach(TYPE, std::chrono::seconds(0), &ec);

CHECK(ret == -1);
CHECK(bool(ec));
CHECK(st.ttl(TYPE, "k") == 10); // untouched
}

TEST_CASE("bleach reports an unknown data type")
{
Fixture f;
auto& st = f.state;

std::error_condition ec;
const ssize_t ret = st.bleach("never-registered",
std::chrono::seconds(1), &ec);

CHECK(ret == -1);
CHECK(ec == SharedStateErrors::UNKOWN_DATA_TYPE);
}

TEST_CASE("bleaching an empty but registered type is a no-op, not an error")
{
Fixture f;
auto& st = f.state;
st.ensureType(TYPE);

std::error_condition ec;
const ssize_t ret = st.bleach(TYPE, std::chrono::seconds(1), &ec);

CHECK(ret == 0);
CHECK_FALSE(bool(ec));
CHECK(st.size(TYPE) == 0);
}
56 changes: 0 additions & 56 deletions tests/debugmesasgetest.cc

This file was deleted.

Loading