Files
HaloKeymind/test/test_mqtt_payload_builder/test_mqtt_payload_builder.cpp
T

331 lines
13 KiB
C++

#include <gtest/gtest.h>
#include <ArduinoJson.h>
#include <cmath>
#include <cstring>
#include <string>
#include <vector>
#include "helpers/MQTTPayloadBuilder.h"
#include "helpers/TxtDataHelpers.h"
namespace {
constexpr const char* kTimestamp = "2026-07-18T12:34:56.123456+00:00";
static int buildMinimalStatus(JsonDocument& scratch, char* buffer, size_t buffer_size) {
return MQTTPayloadBuilder::buildStatusMessage(
scratch, "DEN Repeater", "0123456789ABCDEF", "Heltec V3", "v1.16.0",
"915.000000,62.5,7,5", "MeshCore", "online", kTimestamp,
buffer, buffer_size);
}
static int buildRepresentativePacket(JsonDocument& scratch, const char* direction,
float score, const uint8_t* path, int path_hops,
int path_hash_size, const char* raw,
char* buffer, size_t buffer_size) {
return MQTTPayloadBuilder::buildPacketMessage(
scratch, "DEN Repeater", "0123456789ABCDEF", kTimestamp, direction,
"12:34:56", "18/07/2026", 42, 4, "D", 20, raw,
10.26f, -87, score, "89ABCDEF01234567", path, path_hops,
path_hash_size, 64, buffer, buffer_size);
}
TEST(MQTTPayloadBuilder, MinimalStatusHasExactRequiredContract) {
JsonDocument scratch;
char buffer[768];
int len = buildMinimalStatus(scratch, buffer, sizeof(buffer));
ASSERT_GT(len, 0);
EXPECT_EQ(static_cast<size_t>(len), strlen(buffer));
EXPECT_STREQ(
"{\"status\":\"online\",\"timestamp\":\"2026-07-18T12:34:56.123456+00:00\","
"\"origin\":\"DEN Repeater\",\"origin_id\":\"0123456789ABCDEF\","
"\"model\":\"Heltec V3\",\"firmware_version\":\"v1.16.0\","
"\"radio\":\"915.000000,62.5,7,5\",\"client_version\":\"MeshCore\"}",
buffer);
JsonDocument parsed;
ASSERT_FALSE(deserializeJson(parsed, buffer));
EXPECT_FALSE(parsed["repeat"].is<JsonVariant>());
EXPECT_FALSE(parsed["stats"].is<JsonVariant>());
}
TEST(MQTTPayloadBuilder, StatusIncludesRepeatAndEveryRequestedStatistic) {
JsonDocument scratch;
char buffer[1024];
int len = MQTTPayloadBuilder::buildStatusMessage(
scratch, "node", "id", "model", "firmware", "radio", "client", "online",
kTimestamp, buffer, sizeof(buffer), 4200, 86400, 3, 6, -112,
11, 22, 4, 180864, 31, 47, "on");
ASSERT_GT(len, 0);
JsonDocument parsed;
ASSERT_FALSE(deserializeJson(parsed, buffer));
EXPECT_STREQ("on", parsed["repeat"].as<const char*>());
JsonObject stats = parsed["stats"].as<JsonObject>();
ASSERT_FALSE(stats.isNull());
EXPECT_EQ(4200, stats["battery_mv"].as<int>());
EXPECT_EQ(86400, stats["uptime_secs"].as<int>());
EXPECT_EQ(3, stats["errors"].as<int>());
EXPECT_EQ(6, stats["queue_len"].as<int>());
EXPECT_EQ(-112, stats["noise_floor"].as<int>());
EXPECT_EQ(11, stats["tx_air_secs"].as<int>());
EXPECT_EQ(22, stats["rx_air_secs"].as<int>());
EXPECT_EQ(4, stats["recv_errors"].as<int>());
EXPECT_EQ(180864, stats["internal_heap"].as<int>());
EXPECT_EQ(31, stats["packets_sent"].as<int>());
EXPECT_EQ(47, stats["packets_received"].as<int>());
}
TEST(MQTTPayloadBuilder, StatusOmissionSentinelsRemainOmitted) {
JsonDocument scratch;
char buffer[768];
int len = MQTTPayloadBuilder::buildStatusMessage(
scratch, "node", "id", "model", "firmware", "radio", "client", "online",
kTimestamp, buffer, sizeof(buffer), -1, -1, -1, -1, -999,
-1, -1, -1, -1, -1, -1, nullptr);
ASSERT_GT(len, 0);
JsonDocument parsed;
ASSERT_FALSE(deserializeJson(parsed, buffer));
EXPECT_FALSE(parsed["stats"].is<JsonVariant>());
EXPECT_FALSE(parsed["repeat"].is<JsonVariant>());
}
TEST(MQTTPayloadBuilder, StringsAreEscapedAndRoundTrip) {
const char* origin = "node \"north\"\\rack\nline";
const char* model = "Heltec\tV3";
JsonDocument scratch;
char buffer[1024];
int len = MQTTPayloadBuilder::buildStatusMessage(
scratch, origin, "id", model, "v1", "radio", "client", "online",
kTimestamp, buffer, sizeof(buffer));
ASSERT_GT(len, 0);
EXPECT_NE(std::string::npos, std::string(buffer).find("\\\"north\\\""));
EXPECT_NE(std::string::npos, std::string(buffer).find("\\\\rack\\nline"));
JsonDocument parsed;
ASSERT_FALSE(deserializeJson(parsed, buffer));
EXPECT_STREQ(origin, parsed["origin"].as<const char*>());
EXPECT_STREQ(model, parsed["model"].as<const char*>());
}
TEST(MQTTPayloadBuilder, RxPacketIncludesMetricsScaledScoreAndPath) {
const uint8_t path[] = {0xAA, 0xBB, 0x01, 0x2F};
JsonDocument scratch;
char buffer[2048];
int len = buildRepresentativePacket(
scratch, "rx", 0.125f, path, 2, 2, "A0B1C2D3", buffer, sizeof(buffer));
ASSERT_GT(len, 0);
JsonDocument parsed;
ASSERT_FALSE(deserializeJson(parsed, buffer));
EXPECT_STREQ("PACKET", parsed["type"].as<const char*>());
EXPECT_STREQ("rx", parsed["direction"].as<const char*>());
EXPECT_STREQ("42", parsed["len"].as<const char*>());
EXPECT_STREQ("4", parsed["packet_type"].as<const char*>());
EXPECT_STREQ("20", parsed["payload_len"].as<const char*>());
EXPECT_STREQ("10.3", parsed["SNR"].as<const char*>());
EXPECT_STREQ("-87", parsed["RSSI"].as<const char*>());
EXPECT_STREQ("125", parsed["score"].as<const char*>());
JsonArray parsed_path = parsed["path"].as<JsonArray>();
ASSERT_EQ(2U, parsed_path.size());
EXPECT_STREQ("aabb", parsed_path[0].as<const char*>());
EXPECT_STREQ("012f", parsed_path[1].as<const char*>());
}
TEST(MQTTPayloadBuilder, FixedFloatFormattingDoesNotRequirePrintfFloat) {
char value[20];
EXPECT_TRUE(StrHelper::ftoaFixed(value, sizeof(value), 915.0f, 6));
EXPECT_STREQ("915.000000", value);
EXPECT_TRUE(StrHelper::ftoaFixed(value, sizeof(value), 10.26f, 1));
EXPECT_STREQ("10.3", value);
EXPECT_TRUE(StrHelper::ftoaFixed(value, sizeof(value), -87.25f, 2));
EXPECT_STREQ("-87.25", value);
EXPECT_FALSE(StrHelper::ftoaFixed(value, sizeof(value), std::nanf(""), 1));
EXPECT_STREQ("0", value);
}
TEST(MQTTPayloadBuilder, TxPacketOmitsReceiveOnlyMetricsAndAbsentPath) {
JsonDocument scratch;
char buffer[2048];
int len = buildRepresentativePacket(
scratch, "tx", 0.5f, nullptr, 0, 0, "A0B1", buffer, sizeof(buffer));
ASSERT_GT(len, 0);
JsonDocument parsed;
ASSERT_FALSE(deserializeJson(parsed, buffer));
EXPECT_FALSE(parsed["SNR"].is<JsonVariant>());
EXPECT_FALSE(parsed["RSSI"].is<JsonVariant>());
EXPECT_FALSE(parsed["score"].is<JsonVariant>());
EXPECT_FALSE(parsed["path"].is<JsonVariant>());
}
TEST(MQTTPayloadBuilder, RxPacketOmitsUnknownNanScore) {
JsonDocument scratch;
char buffer[2048];
int len = buildRepresentativePacket(
scratch, "rx", std::nanf(""), nullptr, 0, 0, "A0B1", buffer, sizeof(buffer));
ASSERT_GT(len, 0);
JsonDocument parsed;
ASSERT_FALSE(deserializeJson(parsed, buffer));
EXPECT_TRUE(parsed["SNR"].is<const char*>());
EXPECT_TRUE(parsed["RSSI"].is<const char*>());
EXPECT_FALSE(parsed["score"].is<JsonVariant>());
}
TEST(MQTTPayloadBuilder, RawMessageHasExactContractAndEscapesData) {
char buffer[512];
int len = MQTTPayloadBuilder::buildRawMessage(
"node \"A\"", "id\\1", kTimestamp, "AA\nBB", buffer, sizeof(buffer));
ASSERT_GT(len, 0);
EXPECT_EQ(static_cast<size_t>(len), strlen(buffer));
EXPECT_STREQ(
"{\"origin\":\"node \\\"A\\\"\",\"origin_id\":\"id\\\\1\","
"\"timestamp\":\"2026-07-18T12:34:56.123456+00:00\","
"\"type\":\"RAW\",\"data\":\"AA\\nBB\"}",
buffer);
JsonDocument parsed;
ASSERT_FALSE(deserializeJson(parsed, buffer));
EXPECT_STREQ("AA\nBB", parsed["data"].as<const char*>());
}
TEST(MQTTPayloadBuilder, ExactOutputSizeSucceedsAndOneByteShortFailsCleanly) {
JsonDocument scratch;
char reference[768];
int reference_len = buildMinimalStatus(scratch, reference, sizeof(reference));
ASSERT_GT(reference_len, 0);
std::vector<char> exact(static_cast<size_t>(reference_len) + 1);
EXPECT_EQ(reference_len, buildMinimalStatus(scratch, exact.data(), exact.size()));
EXPECT_STREQ(reference, exact.data());
std::vector<char> short_buffer(static_cast<size_t>(reference_len), 'x');
EXPECT_EQ(0, buildMinimalStatus(scratch, short_buffer.data(), short_buffer.size()));
EXPECT_EQ('\0', short_buffer[0]);
EXPECT_EQ(0, buildMinimalStatus(scratch, short_buffer.data(), 1));
EXPECT_EQ('\0', short_buffer[0]);
EXPECT_EQ(0, buildMinimalStatus(scratch, nullptr, exact.size()));
}
TEST(MQTTPayloadBuilder, MaximumRepresentativePacketAndRawPayloadsRemainValid) {
uint8_t path[64];
for (size_t i = 0; i < sizeof(path); ++i) path[i] = static_cast<uint8_t>(i);
std::string raw(510, 'A');
JsonDocument scratch;
char packet_buffer[2048];
int packet_len = buildRepresentativePacket(
scratch, "rx", 1.0f, path, 16, 4, raw.c_str(),
packet_buffer, sizeof(packet_buffer));
ASSERT_GT(packet_len, 0);
JsonDocument packet;
ASSERT_FALSE(deserializeJson(packet, packet_buffer));
EXPECT_EQ(510U, strlen(packet["raw"].as<const char*>()));
JsonArray parsed_path = packet["path"].as<JsonArray>();
ASSERT_EQ(16U, parsed_path.size());
EXPECT_STREQ("00010203", parsed_path[0].as<const char*>());
EXPECT_STREQ("3c3d3e3f", parsed_path[15].as<const char*>());
char raw_buffer[1024];
int raw_len = MQTTPayloadBuilder::buildRawMessage(
"node", "0123456789ABCDEF", kTimestamp, raw.c_str(),
raw_buffer, sizeof(raw_buffer));
ASSERT_GT(raw_len, 0);
JsonDocument parsed_raw;
ASSERT_FALSE(deserializeJson(parsed_raw, raw_buffer));
EXPECT_EQ(510U, strlen(parsed_raw["data"].as<const char*>()));
}
TEST(MQTTPayloadBuilder, NeighborsMessageRoundTripsSelfAndEntries) {
MQTTPayloadBuilder::NeighborsMessageEntry neighbors[] = {
{"0011223344556677", 9.75f, 42, "DEN,APRS", "active"},
{"8899AABBCCDDEEFF", -3.5f, 3600, "", "stale"},
};
JsonDocument scratch;
char buffer[1024];
int len = MQTTPayloadBuilder::buildNeighborsMessage(
scratch, "DEN Repeater", "0123456789ABCDEF", kTimestamp, "DEN,APRS",
neighbors, 2, buffer, sizeof(buffer));
ASSERT_GT(len, 0);
EXPECT_EQ(static_cast<size_t>(len), strlen(buffer));
JsonDocument parsed;
ASSERT_FALSE(deserializeJson(parsed, buffer));
EXPECT_STREQ("2026-07-18T12:34:56.123456+00:00", parsed["timestamp"].as<const char*>());
EXPECT_STREQ("DEN Repeater", parsed["origin"].as<const char*>());
EXPECT_STREQ("0123456789ABCDEF", parsed["origin_id"].as<const char*>());
EXPECT_STREQ("DEN,APRS", parsed["self"]["scopes"].as<const char*>());
JsonArray arr = parsed["neighbors"].as<JsonArray>();
ASSERT_EQ(2U, arr.size());
EXPECT_STREQ("0011223344556677", arr[0]["pubkey"].as<const char*>());
EXPECT_FLOAT_EQ(9.75f, arr[0]["snr"].as<float>());
EXPECT_EQ(42U, arr[0]["heard_secs_ago"].as<uint32_t>());
EXPECT_STREQ("DEN,APRS", arr[0]["scopes"].as<const char*>());
EXPECT_STREQ("active", arr[0]["status"].as<const char*>());
EXPECT_STREQ("8899AABBCCDDEEFF", arr[1]["pubkey"].as<const char*>());
EXPECT_STREQ("", arr[1]["scopes"].as<const char*>());
EXPECT_STREQ("stale", arr[1]["status"].as<const char*>());
}
TEST(MQTTPayloadBuilder, NeighborsMessageHandlesEmptyTableAndNullScopes) {
JsonDocument scratch;
char buffer[256];
int len = MQTTPayloadBuilder::buildNeighborsMessage(
scratch, "node", "id", kTimestamp, nullptr, nullptr, 0,
buffer, sizeof(buffer));
ASSERT_GT(len, 0);
JsonDocument parsed;
ASSERT_FALSE(deserializeJson(parsed, buffer));
EXPECT_STREQ("", parsed["self"]["scopes"].as<const char*>());
JsonArray arr = parsed["neighbors"].as<JsonArray>();
ASSERT_TRUE(arr.isNull() == false);
EXPECT_EQ(0U, arr.size());
}
TEST(MQTTPayloadBuilder, NeighborsMessageDropsTailWhenBufferFills) {
// Twenty entries far exceed a tight buffer; the builder must emit a prefix
// that still parses as complete JSON rather than truncating mid-document.
MQTTPayloadBuilder::NeighborsMessageEntry neighbors[20];
static char keys[20][17];
for (int i = 0; i < 20; i++) {
snprintf(keys[i], sizeof(keys[i]), "%016X", i);
neighbors[i].pubkey_hex = keys[i];
neighbors[i].snr = static_cast<float>(i);
neighbors[i].heard_secs_ago = static_cast<uint32_t>(i) * 10U;
neighbors[i].scopes = "DEN";
neighbors[i].status = "active";
}
JsonDocument scratch;
char buffer[512];
int len = MQTTPayloadBuilder::buildNeighborsMessage(
scratch, "node", "id", kTimestamp, "DEN", neighbors, 20,
buffer, sizeof(buffer));
ASSERT_GT(len, 0);
EXPECT_LT(static_cast<size_t>(len), sizeof(buffer));
JsonDocument parsed;
ASSERT_FALSE(deserializeJson(parsed, buffer));
JsonArray arr = parsed["neighbors"].as<JsonArray>();
ASSERT_FALSE(arr.isNull());
EXPECT_GT(arr.size(), 0U);
EXPECT_LT(arr.size(), 20U);
// Kept entries are the head of the input, in order.
EXPECT_STREQ(keys[0], arr[0]["pubkey"].as<const char*>());
}
} // namespace
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}