diff --git a/src/monitor/AgentsStatusProvider.cpp b/src/monitor/AgentsStatusProvider.cpp index 6c91b5db..904a7a4d 100644 --- a/src/monitor/AgentsStatusProvider.cpp +++ b/src/monitor/AgentsStatusProvider.cpp @@ -47,8 +47,7 @@ void AgentsStatusProvider::observe(const AgentInformation& info) .toStdString(); AgentConnection agentConn; - agentConn.connection = cpp_restapi::createQtConnection( - m_nam, address, {}); + agentConn.connection = cpp_restapi::createQtConnection(m_nam, address, {}); m_connections.insert(info, std::move(agentConn)); @@ -112,14 +111,17 @@ void AgentsStatusProvider::fetchAgentInfo(const AgentInformation& info) if (it == m_connections.end() || !it->connection) return; - const std::string url = it->connection->url() + "/api/v1/info"; + std::cout << "Fetching info from agent " << info.name().toStdString() << "\n"; QPointer self(this); - it->connection->fetch(url, + it->connection->fetch("api/v1/info", [self, info](cpp_restapi::Response response) { if (!self) return; + + std::cout << "Got info response from agent " << info.name().toStdString() << "\n"; + try { nlohmann::json j = nlohmann::json::parse(response.body); @@ -168,6 +170,8 @@ void AgentsStatusProvider::handleSseEvent(const AgentInformation& info, const cp if (it == m_connections.end()) return; + std::cout << "Got SSE event from agent " << info.name().toStdString() << "\n"; + it->reconnectDelay = std::chrono::milliseconds{1000}; it->lastEventTime = std::chrono::steady_clock::now(); it->connected = true; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 3c0e9414..b717412a 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -16,7 +16,7 @@ if(BUILD_AGENT) endif() if(BUILD_MONITOR) - list(APPEND _test_deps monitorTests) + list(APPEND _test_deps monitorTests monitorIntegrationTests) endif() add_custom_target(RunUnitTests ALL diff --git a/tests/agent/CMakeLists.txt b/tests/agent/CMakeLists.txt index 01d23a60..fbf02d4e 100644 --- a/tests/agent/CMakeLists.txt +++ b/tests/agent/CMakeLists.txt @@ -1,15 +1,20 @@ add_executable(agentTests DiscStatusCalculatorTests.cpp + DiskTests.cpp DmesgParserTests.cpp + LinuxDiskCollectorTests.cpp LsblkOutputParserTests.cpp + OutputParsersUtilsTests.cpp SmartCtlOutputParserTests.cpp SmartHealthAnalyzerTests.cpp + VendorProfileTests.cpp ${PROJECT_SOURCE_DIR}/src/agent/Disk.cpp ${PROJECT_SOURCE_DIR}/src/agent/DiscStatusCalculator.cpp ${PROJECT_SOURCE_DIR}/src/agent/OutputParsersUtils.cpp ${PROJECT_SOURCE_DIR}/src/agent/SmartHealthAnalyzer.cpp ${PROJECT_SOURCE_DIR}/src/agent/linux/DmesgParser.cpp + ${PROJECT_SOURCE_DIR}/src/agent/linux/LinuxDiskCollector.cpp ${PROJECT_SOURCE_DIR}/src/agent/linux/LsblkOutputParser.cpp ${PROJECT_SOURCE_DIR}/src/agent/linux/SmartCtlOutputParser.cpp IPartitionsManagerMock.h diff --git a/tests/agent/DiskTests.cpp b/tests/agent/DiskTests.cpp new file mode 100644 index 00000000..5cf14d5f --- /dev/null +++ b/tests/agent/DiskTests.cpp @@ -0,0 +1,49 @@ +#include + +#include "Disk.h" + + +TEST(DiskTest, exposesConstructorValues) +{ + Disk disk("sda", "Samsung SSD 870", 1024, "SSD"); + + EXPECT_EQ(disk.GetDeviceId(), "sda"); + EXPECT_EQ(disk.GetModel(), "Samsung SSD 870"); + EXPECT_EQ(disk.GetVendor(), "Samsung"); + EXPECT_EQ(disk.GetCapacity(), 1024); + EXPECT_EQ(disk.GetDriveType(), "SSD"); +} + + +TEST(DiskTest, detectsKnownVendorsFromModel) +{ + const std::vector> cases = { + {"Samsung SSD 870", "Samsung"}, + {"WDC WD10EFRX", "WDC"}, + {"Western Digital Blue", "WDC"}, + {"ST4000DM004", "Seagate"}, + {"Seagate IronWolf", "Seagate"}, + {"TOSHIBA HDWD110", "Toshiba"}, + {"HGST HUS724040", "HGST"}, + {"Hitachi HDS721010", "HGST"}, + {"Intel SSDSC2", "Intel"}, + {"Crucial MX500", "Micron"}, + {"Micron 1100", "Micron"}, + {"Kingston SA400", "Kingston"}, + {"SanDisk SDSSDA", "SanDisk"}, + }; + + for (const auto& [model, expectedVendor] : cases) + { + const Disk disk("disk", model); + EXPECT_EQ(disk.GetVendor(), expectedVendor) << model; + } +} + + +TEST(DiskTest, returnsEmptyVendorForUnknownOrShortModel) +{ + EXPECT_TRUE(Disk("disk", "Generic Model").GetVendor().empty()); + EXPECT_TRUE(Disk("disk", "S").GetVendor().empty()); + EXPECT_TRUE(Disk("disk", "").GetVendor().empty()); +} diff --git a/tests/agent/LinuxDiskCollectorTests.cpp b/tests/agent/LinuxDiskCollectorTests.cpp new file mode 100644 index 00000000..fb6df4b5 --- /dev/null +++ b/tests/agent/LinuxDiskCollectorTests.cpp @@ -0,0 +1,57 @@ +#include + +#include "linux/LinuxDiskCollector.h" + + +using testing::ElementsAre; + + +TEST(LinuxDiskCollectorTest, detectsPartitionsFromLsblkEntries) +{ + const std::vector entries = { + {"sda", "disk", 1000, {"sda1", "sda2"}, 8, 0}, + {"nvme0n1", "disk", 2000, {"nvme0n1p1"}, 259, 0}, + }; + + LinuxDiskCollector collector(entries); + + EXPECT_TRUE(collector.isPartition("sda1")); + EXPECT_TRUE(collector.isPartition("sda2")); + EXPECT_TRUE(collector.isPartition("nvme0n1p1")); + EXPECT_FALSE(collector.isPartition("sda")); + EXPECT_FALSE(collector.isPartition("missing")); +} + + +TEST(LinuxDiskCollectorTest, mapsPartitionToParentDisk) +{ + const std::vector entries = { + {"sda", "disk", 1000, {"sda1", "sda2"}, 8, 0}, + {"sdb", "disk", 2000, {"sdb1"}, 8, 16}, + }; + + LinuxDiskCollector collector(entries); + + EXPECT_EQ(collector.diskForPartition("sda1"), "sda"); + EXPECT_EQ(collector.diskForPartition("sda2"), "sda"); + EXPECT_EQ(collector.diskForPartition("sdb1"), "sdb"); + EXPECT_TRUE(collector.diskForPartition("sdc1").empty()); +} + + +TEST(LinuxDiskCollectorTest, buildsDiskListFromEntries) +{ + const std::vector entries = { + {"unit-test-disk-a", "disk", 1000, {}, 8, 0}, + {"unit-test-disk-b", "disk", 2000, {}, 8, 16}, + }; + + LinuxDiskCollector collector(entries); + const auto disks = collector.GetDisksList(); + + ASSERT_EQ(disks.size(), 2); + EXPECT_EQ(disks[0].GetDeviceId(), "unit-test-disk-a"); + EXPECT_EQ(disks[0].GetCapacity(), 1000); + EXPECT_EQ(disks[1].GetDeviceId(), "unit-test-disk-b"); + EXPECT_EQ(disks[1].GetCapacity(), 2000); +} diff --git a/tests/agent/OutputParsersUtilsTests.cpp b/tests/agent/OutputParsersUtilsTests.cpp new file mode 100644 index 00000000..7b147be4 --- /dev/null +++ b/tests/agent/OutputParsersUtilsTests.cpp @@ -0,0 +1,29 @@ +#include + +#include "OutputParsersUtils.h" + + +using testing::ElementsAre; + + +TEST(OutputParsersUtilsTest, trimsLinesAndDropsOuterEmptyLines) +{ + const auto result = ParsersUtils::clean("\n header \n\tvalue\t\n\n tail \n\n"); + + EXPECT_THAT(result, ElementsAre("header", "value", "", "tail")); +} + + +TEST(OutputParsersUtilsTest, preservesInnerEmptyLines) +{ + const auto result = ParsersUtils::clean("first\n\nsecond"); + + EXPECT_THAT(result, ElementsAre("first", "", "second")); +} + + +TEST(OutputParsersUtilsTest, returnsEmptyListForWhitespaceOnlyInput) +{ + EXPECT_TRUE(ParsersUtils::clean(" \n\t\n\r\n").empty()); + EXPECT_TRUE(ParsersUtils::clean("").empty()); +} diff --git a/tests/agent/VendorProfileTests.cpp b/tests/agent/VendorProfileTests.cpp new file mode 100644 index 00000000..69296a42 --- /dev/null +++ b/tests/agent/VendorProfileTests.cpp @@ -0,0 +1,33 @@ +#include + +#include "VendorProfile.h" + + +TEST(VendorProfileTest, genericProfileReturnsRawValue) +{ + GenericProfile profile; + + EXPECT_EQ(profile.interpretRawValue(0x01, 0x112233445566LL), 0x112233445566LL); + EXPECT_EQ(profile.interpretRawValue(0xC3, -10), -10); +} + + +TEST(VendorProfileTest, samsungProfileUsesLower32BitsForPackedAttributes) +{ + SamsungProfile profile; + + EXPECT_EQ(profile.interpretRawValue(0x01, 0x112233445566LL), 0x33445566LL); + EXPECT_EQ(profile.interpretRawValue(0x07, 0x112233445566LL), 0x33445566LL); + EXPECT_EQ(profile.interpretRawValue(0x05, 0x112233445566LL), 0x112233445566LL); +} + + +TEST(VendorProfileTest, seagateProfileUsesLower32BitsForPackedAttributes) +{ + SeagateProfile profile; + + EXPECT_EQ(profile.interpretRawValue(0x01, 0x112233445566LL), 0x33445566LL); + EXPECT_EQ(profile.interpretRawValue(0x07, 0x112233445566LL), 0x33445566LL); + EXPECT_EQ(profile.interpretRawValue(0xC3, 0x112233445566LL), 0x33445566LL); + EXPECT_EQ(profile.interpretRawValue(0x05, 0x112233445566LL), 0x112233445566LL); +} diff --git a/tests/common/CMakeLists.txt b/tests/common/CMakeLists.txt index 8a5951e8..bf254e64 100644 --- a/tests/common/CMakeLists.txt +++ b/tests/common/CMakeLists.txt @@ -1,7 +1,9 @@ add_executable(commonTests ProbeStatusTests.cpp + SmartDataTests.cpp SerializationTests.cpp + UtilsTests.cpp ) target_include_directories(commonTests diff --git a/tests/common/SmartDataTests.cpp b/tests/common/SmartDataTests.cpp new file mode 100644 index 00000000..76f6cabb --- /dev/null +++ b/tests/common/SmartDataTests.cpp @@ -0,0 +1,19 @@ +#include + +#include "SmartData.h" + + +TEST(SmartDataTest, returnsCanonicalNameForKnownAttribute) +{ + EXPECT_EQ(SmartData::GetCanonicalName(0x01), "Raw_Read_Error_Rate"); + EXPECT_EQ(SmartData::GetCanonicalName(0x05), "Reallocated_Sector_Ct"); + EXPECT_EQ(SmartData::GetCanonicalName(0xC5), "Current_Pending_Sector"); + EXPECT_EQ(SmartData::GetCanonicalName(0xFE), "Free_Fall_Sensor"); +} + + +TEST(SmartDataTest, returnsStableUnknownNameForUnknownAttribute) +{ + EXPECT_EQ(SmartData::GetCanonicalName(0x80), "Unknown_Attribute_128"); + EXPECT_EQ(SmartData::GetCanonicalName(0xFF), "Unknown_Attribute_255"); +} diff --git a/tests/common/UtilsTests.cpp b/tests/common/UtilsTests.cpp new file mode 100644 index 00000000..edaa1a23 --- /dev/null +++ b/tests/common/UtilsTests.cpp @@ -0,0 +1,36 @@ +#include + +#include "Utils.h" + + +TEST(UtilsTest, formatsBytesUsingBinaryUnits) +{ + EXPECT_EQ(formatBytes(0), "0 B"); + EXPECT_EQ(formatBytes(1023), "1023 B"); + EXPECT_EQ(formatBytes(1024), "1.00 KiB"); + EXPECT_EQ(formatBytes(1536), "1.50 KiB"); + EXPECT_EQ(formatBytes(1024ULL * 1024ULL * 1024ULL), "1.00 GiB"); +} + + +TEST(UtilsTest, returnsEmptyTableForNoRows) +{ + std::vector> rows; + + EXPECT_TRUE(formatTable(rows).empty()); +} + + +TEST(UtilsTest, formatsRowsIntoPaddedColumns) +{ + std::vector> rows = { + {"Name", "Health"}, + {"sda", "GOOD"}, + {"nvme0n1", "BAD"}, + }; + + EXPECT_EQ(formatTable(rows), + "Name Health \n" + "sda GOOD \n" + "nvme0n1 BAD \n"); +} diff --git a/tests/monitor/AgentInformationTests.cpp b/tests/monitor/AgentInformationTests.cpp new file mode 100644 index 00000000..bc092517 --- /dev/null +++ b/tests/monitor/AgentInformationTests.cpp @@ -0,0 +1,49 @@ +#include + +#include + +#include "AgentInformation.hpp" + + +TEST(AgentInformationTest, exposesConstructorValues) +{ + const AgentInformation info("agent", QHostAddress("192.168.1.10"), 1630, + AgentInformation::DetectionSource::Hardcoded); + + EXPECT_EQ(info.name(), "agent"); + EXPECT_EQ(info.host(), QHostAddress("192.168.1.10")); + EXPECT_EQ(info.port(), 1630); + EXPECT_EQ(info.detectionSource(), AgentInformation::DetectionSource::Hardcoded); +} + + +TEST(AgentInformationTest, equalityAndHashUseEndpointIdentity) +{ + const AgentInformation zeroConf("agent", QHostAddress("192.168.1.10"), 1630, + AgentInformation::DetectionSource::ZeroConf); + const AgentInformation hardcoded("agent", QHostAddress("192.168.1.10"), 1630, + AgentInformation::DetectionSource::Hardcoded); + + EXPECT_EQ(zeroConf, hardcoded); + EXPECT_EQ(qHash(zeroConf, 0), qHash(hardcoded, 0)); + + QSet uniqueAgents; + uniqueAgents.insert(zeroConf); + uniqueAgents.insert(hardcoded); + + EXPECT_EQ(uniqueAgents.size(), 1); +} + + +TEST(AgentInformationTest, endpointDifferencesMakeAgentsDifferent) +{ + const AgentInformation base("agent", QHostAddress("192.168.1.10"), 1630, + AgentInformation::DetectionSource::Hardcoded); + + EXPECT_FALSE(base == AgentInformation("other", QHostAddress("192.168.1.10"), 1630, + AgentInformation::DetectionSource::Hardcoded)); + EXPECT_FALSE(base == AgentInformation("agent", QHostAddress("192.168.1.11"), 1630, + AgentInformation::DetectionSource::Hardcoded)); + EXPECT_FALSE(base == AgentInformation("agent", QHostAddress("192.168.1.10"), 1631, + AgentInformation::DetectionSource::Hardcoded)); +} diff --git a/tests/monitor/AgentsListTests.cpp b/tests/monitor/AgentsListTests.cpp index d3246621..58d5d766 100644 --- a/tests/monitor/AgentsListTests.cpp +++ b/tests/monitor/AgentsListTests.cpp @@ -101,7 +101,7 @@ TEST(AgentsListTest, listofAvailableRoles) listOfRoles.append(it.value()); EXPECT_THAT(listOfRoles, IsSupersetOf( {"agentName", "agentHealth", "agentDetectionType", - "agentHost", "agentPort", "agentConnectionState"} )); + "agentHost", "agentPort", "agentConnectionState"} )); } diff --git a/tests/monitor/AgentsStatusProviderIntegrationTests.cpp b/tests/monitor/AgentsStatusProviderIntegrationTests.cpp new file mode 100644 index 00000000..22677e74 --- /dev/null +++ b/tests/monitor/AgentsStatusProviderIntegrationTests.cpp @@ -0,0 +1,462 @@ +#include +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "AgentsStatusProvider.hpp" +#include "common/JsonSerialize.h" +#include "common/constants.hpp" + + +namespace +{ + constexpr auto WaitTimeout = std::chrono::milliseconds{500}; + + bool waitUntil(const std::function& condition, + std::chrono::milliseconds timeout = WaitTimeout) + { + if (condition()) + return true; + + QEventLoop loop; + QTimer pollTimer; + pollTimer.setInterval(10); + + QTimer timeoutTimer; + timeoutTimer.setSingleShot(true); + + QObject::connect(&pollTimer, &QTimer::timeout, &loop, [&]() { + if (condition()) + loop.quit(); + }); + QObject::connect(&timeoutTimer, &QTimer::timeout, &loop, &QEventLoop::quit); + + pollTimer.start(); + timeoutTimer.start(timeout); + loop.exec(); + + return condition(); + } + + DiskInfo disk(std::string name, GeneralHealth::Health health) + { + DiskSummary summary; + summary.model = "Mock model"; + summary.vendor = "Mock vendor"; + summary.capacityBytes = 1024; + summary.driveType = "SSD"; + + DiskInfo info(std::move(name), health, {}); + info.SetSummary(summary); + + return info; + } + + nlohmann::json statusJson(GeneralHealth::Health health, + const std::vector& disks, + std::string lastRefreshed) + { + return nlohmann::json{ + {"overallHealth", health}, + {"disks", disks}, + {"lastRefreshed", std::move(lastRefreshed)} + }; + } + + nlohmann::json infoJson(std::string name, int protocol = static_cast(VersionOfProtocol)) + { + return nlohmann::json{ + {"name", std::move(name)}, + {"version", "test"}, + {"protocol", protocol} + }; + } + + class MockAgentServer + { + public: + MockAgentServer() + { + m_server.Get("/api/v1/info", [this](const httplib::Request&, httplib::Response& response) { + ++m_infoRequests; + response.set_content(handleInfoRequest().dump(), "application/json"); + }); + + m_server.Get("/api/v1/events", [this](const httplib::Request&, httplib::Response& response) { + ++m_sseSubscriptions; + ++m_openSseSockets; + + auto lastSeenEventId = std::make_shared(-1); + auto initialSent = std::make_shared(false); + + response.set_header("Cache-Control", "no-cache"); + response.set_header("Connection", "keep-alive"); + response.set_chunked_content_provider( + "text/event-stream", + [this, lastSeenEventId, initialSent](size_t, httplib::DataSink& sink) { + std::unique_lock lock(m_mutex); + + if (!*initialSent) + { + *initialSent = true; + *lastSeenEventId = m_eventId; + lock.unlock(); + const std::string event = formatSseEvent(handleSseSubscription().dump()); + sink.write(event.data(), event.size()); + return true; + } + + m_cv.wait_for(lock, std::chrono::milliseconds{100}, [&] { + return m_stopping || m_eventId != *lastSeenEventId; + }); + + if (m_stopping) + return false; + + if (m_eventId == *lastSeenEventId) + return true; + + *lastSeenEventId = m_eventId; + const std::string event = formatSseEvent(m_currentStatus.dump()); + lock.unlock(); + sink.write(event.data(), event.size()); + return true; + }, + [this](bool) { + --m_openSseSockets; + }); + }); + + m_port = static_cast(m_server.bind_to_any_port("127.0.0.1")); + m_thread = std::thread([this] { + m_server.listen_after_bind(); + }); + m_server.wait_until_ready(); + } + + ~MockAgentServer() + { + { + std::lock_guard lock(m_mutex); + m_stopping = true; + } + m_cv.notify_all(); + m_server.stop(); + if (m_thread.joinable()) + m_thread.join(); + } + + quint16 port() const + { + return m_port; + } + + int infoRequests() const + { + return m_infoRequests.load(); + } + + int sseSubscriptions() const + { + return m_sseSubscriptions.load(); + } + + int openSseSockets() const + { + return m_openSseSockets.load(); + } + + void publishStatus(GeneralHealth::Health health, + const std::vector& disks, + std::string lastRefreshed) + { + { + std::lock_guard lock(m_mutex); + m_currentStatus = statusJson(health, disks, std::move(lastRefreshed)); + ++m_eventId; + } + m_cv.notify_all(); + } + + MOCK_METHOD(nlohmann::json, handleInfoRequest, ()); + MOCK_METHOD(nlohmann::json, handleSseSubscription, ()); + + private: + httplib::Server m_server; + std::thread m_thread; + quint16 m_port = 0; + nlohmann::json m_currentStatus; + std::atomic m_infoRequests{0}; + std::atomic m_sseSubscriptions{0}; + std::atomic m_openSseSockets{0}; + std::mutex m_mutex; + std::condition_variable m_cv; + int m_eventId = 0; + bool m_stopping = false; + + static std::string formatSseEvent(const std::string& data) + { + return "event: statusChanged\n" + std::string("data: ") + data + "\n\n"; + } + }; + + AgentInformation agentInfo(const QString& name, quint16 port) + { + return AgentInformation(name, + QHostAddress::LocalHost, + port, + AgentInformation::DetectionSource::Hardcoded); + } + + struct AgentStatusProviderStatesHistory + { + std::vector connectionStates; + std::vector statuses; + std::vector> diskCollections; + QString lastRefreshed; + }; + + void connectProvider(AgentsStatusProvider& provider, const AgentInformation& info, AgentStatusProviderStatesHistory& statesHistory) + { + QObject::connect(&provider, &IAgentsStatusProvider::connectionStateChanged, + [&](const AgentInformation& changedAgent, ConnectionState state) { + if (changedAgent == info) + statesHistory.connectionStates.push_back(state); + }); + QObject::connect(&provider, &IAgentsStatusProvider::statusChanged, + [&](const AgentInformation& changedAgent, GeneralHealth::Health health) { + if (changedAgent == info) + statesHistory.statuses.push_back(health); + }); + QObject::connect(&provider, &IAgentsStatusProvider::diskCollectionChanged, + [&](const AgentInformation& changedAgent, const std::vector& disks) { + if (changedAgent == info) + statesHistory.diskCollections.push_back(disks); + }); + QObject::connect(&provider, &IAgentsStatusProvider::lastRefreshedChanged, + [&](const AgentInformation& changedAgent, const QString& timestamp) { + if (changedAgent == info) + statesHistory.lastRefreshed = timestamp; + }); + } +} + + +TEST(AgentsStatusProviderIntegrationTest, readsInitialAgentStateFromFirstSseEvent) +{ + MockAgentServer mockAgent; + EXPECT_CALL(mockAgent, handleInfoRequest()) + .WillOnce(testing::Return(infoJson("alpha"))); + EXPECT_CALL(mockAgent, handleSseSubscription()) + .WillOnce(testing::Return(statusJson(GeneralHealth::GOOD, + {disk("sda", GeneralHealth::GOOD)}, + "2026-05-04T10:00:00Z"))); + + AgentsStatusProvider provider; + const AgentInformation info = agentInfo("alpha", mockAgent.port()); + AgentStatusProviderStatesHistory history; + + connectProvider(provider, info, history); + provider.observe(info); + + ASSERT_TRUE(waitUntil([&] { return !history.statuses.empty() && !history.diskCollections.empty(); })); + ASSERT_TRUE(waitUntil([&] { return mockAgent.infoRequests() == 1 && mockAgent.sseSubscriptions() == 1; })); + + EXPECT_EQ(history.connectionStates.front(), ConnectionState::Connecting); + EXPECT_EQ(history.connectionStates.back(), ConnectionState::Connected); + EXPECT_EQ(history.statuses.back(), GeneralHealth::GOOD); + ASSERT_EQ(history.diskCollections.back().size(), 1); + EXPECT_EQ(history.diskCollections.back().front().GetName(), "sda"); + EXPECT_EQ(history.lastRefreshed, "2026-05-04T10:00:00Z"); +} + + +TEST(AgentsStatusProviderIntegrationTest, reactsToSseStatusNotifications) +{ + MockAgentServer mockAgent; + EXPECT_CALL(mockAgent, handleInfoRequest()) + .WillOnce(testing::Return(infoJson("alpha"))); + EXPECT_CALL(mockAgent, handleSseSubscription()) + .WillOnce(testing::Return(statusJson(GeneralHealth::GOOD, + {disk("sda", GeneralHealth::GOOD)}, + "2026-05-04T10:00:00Z"))); + + AgentsStatusProvider provider; + const AgentInformation info = agentInfo("alpha", mockAgent.port()); + AgentStatusProviderStatesHistory history; + + connectProvider(provider, info, history); + provider.observe(info); + + ASSERT_TRUE(waitUntil([&] { return history.statuses.size() == 1 && !history.diskCollections.empty(); })); + + mockAgent.publishStatus(GeneralHealth::BAD, + {disk("sdb", GeneralHealth::BAD), disk("nvme0n1", GeneralHealth::GOOD)}, + "2026-05-04T10:05:00Z"); + + ASSERT_TRUE(waitUntil([&] { return history.statuses.size() >= 2 && history.diskCollections.back().size() == 2; })); + + EXPECT_EQ(history.statuses.back(), GeneralHealth::BAD); + EXPECT_EQ(history.diskCollections.back().at(0).GetName(), "sdb"); + EXPECT_EQ(history.diskCollections.back().at(1).GetName(), "nvme0n1"); + EXPECT_EQ(history.lastRefreshed, "2026-05-04T10:05:00Z"); +} + + +TEST(AgentsStatusProviderIntegrationTest, keepsMultipleAgentsSeparated) +{ + MockAgentServer alpha; + MockAgentServer beta; + EXPECT_CALL(alpha, handleInfoRequest()) + .WillOnce(testing::Return(infoJson("alpha"))); + EXPECT_CALL(alpha, handleSseSubscription()) + .WillOnce(testing::Return(statusJson(GeneralHealth::GOOD, + {disk("sda", GeneralHealth::GOOD)}, + "2026-05-04T10:00:00Z"))); + EXPECT_CALL(beta, handleInfoRequest()) + .WillOnce(testing::Return(infoJson("beta"))); + EXPECT_CALL(beta, handleSseSubscription()) + .WillOnce(testing::Return(statusJson(GeneralHealth::BAD, + {disk("sdb", GeneralHealth::BAD)}, + "2026-05-04T10:00:00Z"))); + + AgentsStatusProvider provider; + + const AgentInformation alphaInfo = agentInfo("alpha", alpha.port()); + const AgentInformation betaInfo = agentInfo("beta", beta.port()); + + QHash statuses; + QHash diskNames; + + QObject::connect(&provider, &IAgentsStatusProvider::statusChanged, + [&](const AgentInformation& agent, GeneralHealth::Health health) { + statuses[agent] = health; + }); + QObject::connect(&provider, &IAgentsStatusProvider::diskCollectionChanged, + [&](const AgentInformation& agent, const std::vector& disks) { + QStringList names; + for (const auto& diskInfo : disks) + names.append(QString::fromStdString(diskInfo.GetName())); + diskNames[agent] = names; + }); + + provider.observe(alphaInfo); + provider.observe(betaInfo); + + ASSERT_TRUE(waitUntil([&] { + return statuses.contains(alphaInfo) && statuses.contains(betaInfo) + && diskNames.contains(alphaInfo) && diskNames.contains(betaInfo); + })); + + EXPECT_EQ(statuses[alphaInfo], GeneralHealth::GOOD); + EXPECT_EQ(statuses[betaInfo], GeneralHealth::BAD); + EXPECT_EQ(diskNames[alphaInfo], QStringList{"sda"}); + EXPECT_EQ(diskNames[betaInfo], QStringList{"sdb"}); + + beta.publishStatus(GeneralHealth::CHECK_STATUS, + {disk("sdc", GeneralHealth::CHECK_STATUS)}, + "2026-05-04T10:10:00Z"); + + ASSERT_TRUE(waitUntil([&] { + return statuses[betaInfo] == GeneralHealth::CHECK_STATUS + && diskNames[betaInfo] == QStringList{"sdc"}; + })); + + EXPECT_EQ(statuses[alphaInfo], GeneralHealth::GOOD); + EXPECT_EQ(diskNames[alphaInfo], QStringList{"sda"}); +} + + +TEST(AgentsStatusProviderIntegrationTest, reportsProtocolMismatchFromInfoEndpoint) +{ + const int unsupportedProtocol = static_cast(VersionOfProtocol) + 1; + MockAgentServer mockAgent; + EXPECT_CALL(mockAgent, handleInfoRequest()) + .WillOnce(testing::Return(infoJson("alpha", unsupportedProtocol))); + EXPECT_CALL(mockAgent, handleSseSubscription()) + .WillOnce(testing::Return(statusJson(GeneralHealth::GOOD, + {disk("sda", GeneralHealth::GOOD)}, + "2026-05-04T10:00:00Z"))); + + AgentsStatusProvider provider; + const AgentInformation info = agentInfo("alpha", mockAgent.port()); + + int reportedAgentVersion = 0; + int reportedMonitorVersion = 0; + + QObject::connect(&provider, &IAgentsStatusProvider::protocolMismatch, + [&](const AgentInformation& changedAgent, int agentVersion, int monitorVersion) { + if (changedAgent == info) + { + reportedAgentVersion = agentVersion; + reportedMonitorVersion = monitorVersion; + } + }); + + provider.observe(info); + + ASSERT_TRUE(waitUntil([&] { return reportedAgentVersion != 0; })); + + EXPECT_EQ(reportedAgentVersion, unsupportedProtocol); + EXPECT_EQ(reportedMonitorVersion, static_cast(VersionOfProtocol)); +} + + +TEST(AgentsStatusProviderIntegrationTest, unobserveClosesSseAndIgnoresLaterAgentNotifications) +{ + MockAgentServer mockAgent; + EXPECT_CALL(mockAgent, handleInfoRequest()) + .WillOnce(testing::Return(infoJson("alpha"))); + EXPECT_CALL(mockAgent, handleSseSubscription()) + .WillOnce(testing::Return(statusJson(GeneralHealth::GOOD, + {disk("sda", GeneralHealth::GOOD)}, + "2026-05-04T10:00:00Z"))); + + AgentsStatusProvider provider; + const AgentInformation info = agentInfo("alpha", mockAgent.port()); + + std::vector statuses; + + QObject::connect(&provider, &IAgentsStatusProvider::statusChanged, + [&](const AgentInformation& changedAgent, GeneralHealth::Health health) { + if (changedAgent == info) + statuses.push_back(health); + }); + + provider.observe(info); + + ASSERT_TRUE(waitUntil([&] { return statuses.size() == 1 && mockAgent.openSseSockets() == 1; })); + + provider.unobserve(info); + + ASSERT_TRUE(waitUntil([&] { return mockAgent.openSseSockets() == 0; })); + + mockAgent.publishStatus(GeneralHealth::BAD, {disk("sdb", GeneralHealth::BAD)}, "2026-05-04T10:15:00Z"); + QTest::qWait(100); + + ASSERT_EQ(statuses.size(), 1); + EXPECT_EQ(statuses.back(), GeneralHealth::GOOD); +} + + +int main(int argc, char** argv) +{ + QCoreApplication app(argc, argv); + testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/tests/monitor/CMakeLists.txt b/tests/monitor/CMakeLists.txt index b2e8ce8f..08913361 100644 --- a/tests/monitor/CMakeLists.txt +++ b/tests/monitor/CMakeLists.txt @@ -2,12 +2,19 @@ find_package(Qt6 REQUIRED COMPONENTS Core Network Test) add_executable(monitorTests + AgentInformationTests.cpp AgentsListTests.cpp + ConfigurationTests.cpp IAgentsStatusProviderMock.hpp + ManualAgentsValidatorTests.cpp + ${PROJECT_SOURCE_DIR}/src/monitor/Configuration.cpp + ${PROJECT_SOURCE_DIR}/src/monitor/Configuration.hpp ${PROJECT_SOURCE_DIR}/src/monitor/AgentsList.cpp ${PROJECT_SOURCE_DIR}/src/monitor/AgentsList.hpp ${PROJECT_SOURCE_DIR}/src/monitor/AgentInformation.cpp ${PROJECT_SOURCE_DIR}/src/monitor/IAgentsStatusProvider.hpp + ${PROJECT_SOURCE_DIR}/src/monitor/ManualAgentsValidator.cpp + ${PROJECT_SOURCE_DIR}/src/monitor/ManualAgentsValidator.hpp ) target_include_directories(monitorTests @@ -32,3 +39,42 @@ add_test( NAME monitorTests COMMAND monitorTests ) + +add_executable(monitorIntegrationTests + AgentsStatusProviderIntegrationTests.cpp + ${PROJECT_SOURCE_DIR}/src/monitor/AgentsStatusProvider.cpp + ${PROJECT_SOURCE_DIR}/src/monitor/AgentsStatusProvider.hpp + ${PROJECT_SOURCE_DIR}/src/monitor/AgentInformation.cpp + ${PROJECT_SOURCE_DIR}/src/monitor/AgentInformation.hpp + ${PROJECT_SOURCE_DIR}/src/monitor/IAgentsStatusProvider.hpp +) + +target_include_directories(monitorIntegrationTests + PRIVATE + ${PROJECT_SOURCE_DIR}/src/monitor + ${PROJECT_SOURCE_DIR}/src + ${PROJECT_SOURCE_DIR}/external/nlohmann-json/single_include +) + +target_include_directories(monitorIntegrationTests + SYSTEM PRIVATE + ${PROJECT_SOURCE_DIR}/external/cpp-httplib +) + +target_link_libraries(monitorIntegrationTests + common + cpp_restapi + gmock + gtest + + Qt::Core + Qt::Network + Qt::Test +) + +set_target_properties(monitorIntegrationTests PROPERTIES AUTOMOC TRUE) + +add_test( + NAME monitorIntegrationTests + COMMAND monitorIntegrationTests +) diff --git a/tests/monitor/ConfigurationTests.cpp b/tests/monitor/ConfigurationTests.cpp new file mode 100644 index 00000000..0b5b28e2 --- /dev/null +++ b/tests/monitor/ConfigurationTests.cpp @@ -0,0 +1,96 @@ +#include + +#include +#include + +#include "Configuration.hpp" +#include "common/constants.hpp" + + +namespace +{ + void useTemporarySettingsPath(const QTemporaryDir& dir) + { + QSettings::setPath(QSettings::IniFormat, QSettings::UserScope, dir.path()); + QSettings settings(QSettings::IniFormat, QSettings::UserScope, + QString::fromStdString(ApplicationShortName), + QString::fromStdString(ApplicationShortName)); + settings.clear(); + settings.sync(); + } +} + + +TEST(ConfigurationTest, persistsAgentsAcrossInstances) +{ + QTemporaryDir settingsDir; + ASSERT_TRUE(settingsDir.isValid()); + useTemporarySettingsPath(settingsDir); + + const QVector agents = { + AgentInformation("Agent, One; Encoded", QHostAddress("192.168.1.10"), 1630, + AgentInformation::DetectionSource::Hardcoded), + AgentInformation("ZeroConf Agent", QHostAddress("fe80::1"), 1631, + AgentInformation::DetectionSource::ZeroConf), + }; + + { + Configuration configuration; + configuration.storeAgents(agents); + } + + { + Configuration configuration; + const auto restored = configuration.readAgents(); + + ASSERT_EQ(restored.size(), agents.size()); + EXPECT_EQ(restored[0], agents[0]); + EXPECT_EQ(restored[0].detectionSource(), agents[0].detectionSource()); + EXPECT_EQ(restored[1], agents[1]); + EXPECT_EQ(restored[1].detectionSource(), agents[1].detectionSource()); + } +} + + +TEST(ConfigurationTest, storesEmptyAgentList) +{ + QTemporaryDir settingsDir; + ASSERT_TRUE(settingsDir.isValid()); + useTemporarySettingsPath(settingsDir); + + { + Configuration configuration; + configuration.storeAgents({}); + } + + { + Configuration configuration; + EXPECT_TRUE(configuration.readAgents().empty()); + } +} + + +TEST(ConfigurationTest, skipsMalformedAgentEntries) +{ + QTemporaryDir settingsDir; + ASSERT_TRUE(settingsDir.isValid()); + useTemporarySettingsPath(settingsDir); + + const QString encodedName = QString::fromUtf8(QByteArray("valid").toBase64()); + QSettings settings(QSettings::IniFormat, QSettings::UserScope, + QString::fromStdString(ApplicationShortName), + QString::fromStdString(ApplicationShortName)); + settings.setValue("agents", + QString("too,few,fields;%1,127.0.0.1,1630,1;too,many,fields,for,agent") + .arg(encodedName)); + settings.sync(); + + Configuration configuration; + const auto restored = configuration.readAgents(); + + ASSERT_EQ(restored.size(), 1); + EXPECT_EQ(restored[0].name(), "valid"); + EXPECT_EQ(restored[0].host(), QHostAddress("127.0.0.1")); + EXPECT_EQ(restored[0].port(), 1630); + EXPECT_EQ(restored[0].detectionSource(), AgentInformation::DetectionSource::Hardcoded); +} diff --git a/tests/monitor/ManualAgentsValidatorTests.cpp b/tests/monitor/ManualAgentsValidatorTests.cpp new file mode 100644 index 00000000..7da17314 --- /dev/null +++ b/tests/monitor/ManualAgentsValidatorTests.cpp @@ -0,0 +1,98 @@ +#include + +#include + +#include "ManualAgentsValidator.hpp" + + +namespace +{ + struct ValidationResult + { + std::optional discoveredAgent; + std::vector failures; + }; + + ValidationResult validate(const QString& name, const QString& ip, const QString& port) + { + ManualAgentsValidator validator; + ValidationResult result; + + QObject::connect(&validator, &ManualAgentsValidator::agentDiscovered, + [&result](const AgentInformation& info) { + result.discoveredAgent = info; + }); + QObject::connect(&validator, &ManualAgentsValidator::validationFailed, + [&result](const QString& reason) { + result.failures.push_back(reason); + }); + + validator.addNewAgent(name, ip, port); + + return result; + } +} + + +TEST(ManualAgentsValidatorTest, rejectsEmptyAgentName) +{ + const auto result = validate(" \t ", "192.168.1.10", "1630"); + + EXPECT_FALSE(result.discoveredAgent.has_value()); + ASSERT_EQ(result.failures.size(), 1); + EXPECT_EQ(result.failures[0], "Agent name cannot be empty"); +} + + +TEST(ManualAgentsValidatorTest, rejectsInvalidIpAddress) +{ + const std::vector invalidIps = { + "", + "192.168.1", + "192.168.1.1.1", + "192.168.1.256", + "192.168.one.1", + }; + + for (const auto& ip : invalidIps) + { + const auto result = validate("agent", ip, "1630"); + + EXPECT_FALSE(result.discoveredAgent.has_value()) << ip.toStdString(); + ASSERT_EQ(result.failures.size(), 1) << ip.toStdString(); + EXPECT_EQ(result.failures[0], "Invalid IP address") << ip.toStdString(); + } +} + + +TEST(ManualAgentsValidatorTest, rejectsInvalidPort) +{ + const std::vector invalidPorts = { + "", + "0", + "65536", + "abc", + }; + + for (const auto& port : invalidPorts) + { + const auto result = validate("agent", "192.168.1.10", port); + + EXPECT_FALSE(result.discoveredAgent.has_value()) << port.toStdString(); + ASSERT_EQ(result.failures.size(), 1) << port.toStdString(); + EXPECT_EQ(result.failures[0], "Port must be between 1 and 65535") << port.toStdString(); + } +} + + +TEST(ManualAgentsValidatorTest, emitsTrimmedHardcodedAgentForValidInput) +{ + const auto result = validate(" agent ", "10.0.0.5", "65535"); + + ASSERT_TRUE(result.discoveredAgent.has_value()); + EXPECT_TRUE(result.failures.empty()); + EXPECT_EQ(result.discoveredAgent->name(), "agent"); + EXPECT_EQ(result.discoveredAgent->host(), QHostAddress("10.0.0.5")); + EXPECT_EQ(result.discoveredAgent->port(), 65535); + EXPECT_EQ(result.discoveredAgent->detectionSource(), AgentInformation::DetectionSource::Hardcoded); +}