diff --git a/tests/fne/PatchStatusRegistry_Tests.cpp b/tests/fne/PatchStatusRegistry_Tests.cpp new file mode 100644 index 00000000..a1300fdf --- /dev/null +++ b/tests/fne/PatchStatusRegistry_Tests.cpp @@ -0,0 +1,278 @@ +// SPDX-License-Identifier: GPL-2.0-only +/* + * Digital Voice Modem - Test Suite + * GPLv2 Open Source. Use is subject to license terms. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * Copyright (C) 2026 Bryan Biedenkapp, N2PLL + * + */ + +#include + +#include "fne/PatchStatusRegistry.h" + +#include +#include +#include +#include + +namespace { + /** + * @brief Creates a JSON object representing a patch member. + * @param system The system name of the patch member. + * @param mode The mode of the patch member. + * @param tgid The talkgroup ID of the patch member. + * @param slot The slot number of the patch member. + * @return json::object The JSON representation of the patch member. + */ + json::object member(const std::string& system, const std::string& mode, + uint32_t tgid, uint32_t slot) + { + json::object value = json::object(); + value["system"].set(system); + value["mode"].set(mode); + value["tgid"].set(tgid); + value["slot"].set(slot); + return value; + } + + /** + * @brief Creates a JSON object representing a patch. + * @param patchId The ID of the patch. + * @param members The array of patch members. + * @param active Whether the patch is active. + * @param oneWay Whether the patch is one-way. + * @return json::object The JSON representation of the patch. + */ + json::object patch(const std::string& patchId, const json::array& members, + bool active = true, bool oneWay = false) + { + json::object value = json::object(); + value["patchId"].set(patchId); + value["active"].set(active); + value["oneWay"].set(oneWay); + value["members"].set(members); + return value; + } + + /** + * @brief Creates a JSON object representing a publish request. + * @param peerId The ID of the peer making the request. + * @param sequence The sequence number of the request. + * @param patches The array of patches being published. + * @param ttlSeconds The time-to-live for the request. + * @param originFnePeerId The originating FNE peer ID. + * @return json::object The JSON representation of the publish request. + */ + json::object publishRequest(uint32_t peerId, uint32_t sequence, + const json::array& patches, uint32_t ttlSeconds = 15U, + uint32_t originFnePeerId = 0U) + { + json::object request = json::object(); + request["peerId"].set(peerId); + request["peerName"].set("Dispatch Console"); + request["originFnePeerId"].set(originFnePeerId); + request["sequence"].set(sequence); + request["ttlSeconds"].set(ttlSeconds); + request["patches"].set(patches); + return request; + } + + /** + * @brief Publishes a patch status update to the registry. + * @param registry The patch status registry instance. + * @param request The JSON publish request. + * @param response The JSON response object to be populated. + * @param error The error string to be populated in case of failure. + * @param changed Optional pointer to a boolean indicating if the registry changed. + * @return bool True if the publish was successful, false otherwise. + */ + bool publish(PatchStatusRegistry& registry, json::object request, + json::object& response, std::string& error, bool* changed = nullptr) + { + return registry.publish(request, response, error, changed); + } +} + +TEST_CASE("FNE patch status registry publishes normalized indexed snapshots", + "[fne][patch-status]") +{ + PatchStatusRegistry registry; + json::array members = json::array(); + members.push_back(json::value(member("SYS1", "TG3100", 3100U, 2U))); + members.push_back(json::value(member("SYS2", "TG4100", 4100U, 0U))); + + json::array patches = json::array(); + patches.push_back(json::value(patch("patch-7", members, true, true))); + json::object response; + std::string error; + bool changed = false; + + REQUIRE(publish(registry, publishRequest(1001U, 7U, patches, 30U, 9001U), + response, error, &changed)); + REQUIRE(changed); + REQUIRE(error.empty()); + REQUIRE(response["revision"].get() == 1U); + REQUIRE(response["acceptedPeerId"].get() == 1001U); + REQUIRE(response["ttlSeconds"].get() == 30U); + + json::array peerSnapshots = response["peers"].get(); + REQUIRE(peerSnapshots.size() == 1U); + json::object peer = peerSnapshots[0U].get(); + REQUIRE(peer["peerId"].get() == 1001U); + REQUIRE(peer["originFnePeerId"].get() == 9001U); + REQUIRE(peer["sequence"].get() == 7U); + + json::object index = response["byTalkgroup"].get(); + REQUIRE(index["tg3100:3100:2"].get().size() == 1U); + REQUIRE(index["tg4100:4100:0"].get().size() == 1U); + json::object indexed = index["tg3100:3100:2"].get()[0U].get(); + REQUIRE(indexed["patchId"].get() == "patch-7"); + REQUIRE(indexed["oneWay"].get()); + json::object indexedMember = indexed["member"].get(); + REQUIRE(indexedMember["mode"].get() == "tg3100"); + REQUIRE(indexedMember["key"].get() == "tg3100:3100:2"); +} + +TEST_CASE("FNE patch status registry applies sequence and replacement semantics", + "[fne][patch-status]") +{ + PatchStatusRegistry registry; + json::array firstMembers = json::array(); + firstMembers.push_back(json::value(member("A", "P25", 100U, 1U))); + json::array firstPatches = json::array(); + firstPatches.push_back(json::value(patch("first", firstMembers))); + + json::object response; + std::string error; + bool changed = false; + REQUIRE(publish(registry, publishRequest(42U, 10U, firstPatches, 15U, 7U), + response, error, &changed)); + REQUIRE(changed); + + changed = true; + REQUIRE(publish(registry, publishRequest(42U, 9U, json::array(), 15U, 7U), + response, error, &changed)); + REQUIRE_FALSE(changed); + REQUIRE(registry.revision() == 1U); + REQUIRE(response["patches"].get().size() == 1U); + + changed = false; + REQUIRE(publish(registry, publishRequest(42U, 9U, json::array(), 15U, 8U), + response, error, &changed)); + REQUIRE(changed); + REQUIRE(registry.revision() == 2U); + REQUIRE(response["patches"].get().empty()); + + REQUIRE_FALSE(registry.removePeer(42U)); + REQUIRE_FALSE(registry.removePeer(0U)); +} + +TEST_CASE("FNE patch status registry does not revise identical snapshots", + "[fne][patch-status]") +{ + PatchStatusRegistry registry; + json::array members = json::array(); + members.push_back(json::value(member("A", "P25", 1200U, 0U))); + json::array patches = json::array(); + patches.push_back(json::value(patch("same", members))); + + json::object response; + std::string error; + bool changed = false; + json::object request = publishRequest(77U, 0U, patches); + REQUIRE(publish(registry, request, response, error, &changed)); + REQUIRE(changed); + + changed = true; + REQUIRE(publish(registry, request, response, error, &changed)); + REQUIRE_FALSE(changed); + REQUIRE(registry.revision() == 1U); + + REQUIRE(registry.removePeer(77U)); + REQUIRE(registry.revision() == 2U); + REQUIRE(registry.snapshot()["peers"].get().empty()); +} + +TEST_CASE("FNE patch status registry validates malformed and oversized publishes", + "[fne][patch-status][validation]") +{ + PatchStatusRegistry registry; + json::object response; + std::string error; + + json::object missingPeer = json::object(); + missingPeer["patches"].set(json::array()); + REQUIRE_FALSE(publish(registry, missingPeer, response, error)); + REQUIRE(error == "peerId was not a valid integer"); + + json::array members = json::array(); + members.push_back(json::value(member("A", "P25", 0U, 1U))); + json::array patches = json::array(); + patches.push_back(json::value(patch("bad-tgid", members))); + error.clear(); + REQUIRE_FALSE(publish(registry, publishRequest(1U, 1U, patches), response, error)); + REQUIRE(error == "patch member tgid cannot be zero"); + + members = json::array(); + members.push_back(json::value(member("A", "P25", 1U, 256U))); + patches = json::array(); + patches.push_back(json::value(patch("bad-slot", members))); + error.clear(); + REQUIRE_FALSE(publish(registry, publishRequest(1U, 1U, patches), response, error)); + REQUIRE(error == "patch member slot was out of range"); + + patches = json::array(); + for (size_t i = 0U; i <= PatchStatusRegistry::MAX_PATCHES_PER_PEER; i++) + patches.push_back(json::value(patch(std::to_string(i), json::array()))); + error.clear(); + REQUIRE_FALSE(publish(registry, publishRequest(1U, 1U, patches), response, error)); + REQUIRE(error == "patches exceeded the maximum allowed count"); + REQUIRE(registry.revision() == 0U); +} + +TEST_CASE("FNE patch status registry clamps TTL and expires records", + "[fne][patch-status][ttl]") +{ + PatchStatusRegistry registry; + registry.configure(0U, 1U, 2U); + REQUIRE(registry.defaultTtlSeconds() == 1U); + REQUIRE(registry.minTtlSeconds() == 1U); + REQUIRE(registry.maxTtlSeconds() == 2U); + + json::array members = json::array(); + members.push_back(json::value(member("A", "P25", 101U, 0U))); + json::array patches = json::array(); + patches.push_back(json::value(patch("short-lived", members))); + json::object response; + std::string error; + REQUIRE(publish(registry, publishRequest(55U, 1U, patches, 0U), response, error)); + REQUIRE(response["ttlSeconds"].get() == 1U); + + std::this_thread::sleep_for(std::chrono::milliseconds(1100)); + REQUIRE(registry.cleanupExpired() == 1U); + REQUIRE(registry.revision() == 2U); + REQUIRE(registry.snapshot()["peers"].get().empty()); +} + +TEST_CASE("FNE patch status registry long poll wakes on revision change", + "[fne][patch-status][long-poll]") +{ + PatchStatusRegistry registry; + std::future waiting = std::async(std::launch::async, [®istry]() { + return registry.waitForChanges(0U, 2000U); + }); + + std::this_thread::sleep_for(std::chrono::milliseconds(25)); + json::array members = json::array(); + members.push_back(json::value(member("A", "P25", 101U, 1U))); + json::array patches = json::array(); + patches.push_back(json::value(patch("wake", members))); + json::object response; + std::string error; + REQUIRE(publish(registry, publishRequest(88U, 1U, patches), response, error)); + + REQUIRE(waiting.wait_for(std::chrono::milliseconds(500)) == std::future_status::ready); + REQUIRE(waiting.get()["revision"].get() == 1U); +}