#include #include #include #include #include #include #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(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()); EXPECT_FALSE(parsed["stats"].is()); } 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()); JsonObject stats = parsed["stats"].as(); ASSERT_FALSE(stats.isNull()); EXPECT_EQ(4200, stats["battery_mv"].as()); EXPECT_EQ(86400, stats["uptime_secs"].as()); EXPECT_EQ(3, stats["errors"].as()); EXPECT_EQ(6, stats["queue_len"].as()); EXPECT_EQ(-112, stats["noise_floor"].as()); EXPECT_EQ(11, stats["tx_air_secs"].as()); EXPECT_EQ(22, stats["rx_air_secs"].as()); EXPECT_EQ(4, stats["recv_errors"].as()); EXPECT_EQ(180864, stats["internal_heap"].as()); EXPECT_EQ(31, stats["packets_sent"].as()); EXPECT_EQ(47, stats["packets_received"].as()); } 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()); EXPECT_FALSE(parsed["repeat"].is()); } 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()); EXPECT_STREQ(model, parsed["model"].as()); } 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()); EXPECT_STREQ("rx", parsed["direction"].as()); EXPECT_STREQ("42", parsed["len"].as()); EXPECT_STREQ("4", parsed["packet_type"].as()); EXPECT_STREQ("20", parsed["payload_len"].as()); EXPECT_STREQ("10.3", parsed["SNR"].as()); EXPECT_STREQ("-87", parsed["RSSI"].as()); EXPECT_STREQ("125", parsed["score"].as()); JsonArray parsed_path = parsed["path"].as(); ASSERT_EQ(2U, parsed_path.size()); EXPECT_STREQ("aabb", parsed_path[0].as()); EXPECT_STREQ("012f", parsed_path[1].as()); } 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()); EXPECT_FALSE(parsed["RSSI"].is()); EXPECT_FALSE(parsed["score"].is()); EXPECT_FALSE(parsed["path"].is()); } 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()); EXPECT_TRUE(parsed["RSSI"].is()); EXPECT_FALSE(parsed["score"].is()); } 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(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()); } TEST(MQTTPayloadBuilder, ExactOutputSizeSucceedsAndOneByteShortFailsCleanly) { JsonDocument scratch; char reference[768]; int reference_len = buildMinimalStatus(scratch, reference, sizeof(reference)); ASSERT_GT(reference_len, 0); std::vector exact(static_cast(reference_len) + 1); EXPECT_EQ(reference_len, buildMinimalStatus(scratch, exact.data(), exact.size())); EXPECT_STREQ(reference, exact.data()); std::vector short_buffer(static_cast(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(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())); JsonArray parsed_path = packet["path"].as(); ASSERT_EQ(16U, parsed_path.size()); EXPECT_STREQ("00010203", parsed_path[0].as()); EXPECT_STREQ("3c3d3e3f", parsed_path[15].as()); 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())); } 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(len), strlen(buffer)); JsonDocument parsed; ASSERT_FALSE(deserializeJson(parsed, buffer)); EXPECT_STREQ("2026-07-18T12:34:56.123456+00:00", parsed["timestamp"].as()); EXPECT_STREQ("DEN Repeater", parsed["origin"].as()); EXPECT_STREQ("0123456789ABCDEF", parsed["origin_id"].as()); EXPECT_STREQ("DEN,APRS", parsed["self"]["scopes"].as()); JsonArray arr = parsed["neighbors"].as(); ASSERT_EQ(2U, arr.size()); EXPECT_STREQ("0011223344556677", arr[0]["pubkey"].as()); EXPECT_FLOAT_EQ(9.75f, arr[0]["snr"].as()); EXPECT_EQ(42U, arr[0]["heard_secs_ago"].as()); EXPECT_STREQ("DEN,APRS", arr[0]["scopes"].as()); EXPECT_STREQ("active", arr[0]["status"].as()); EXPECT_STREQ("8899AABBCCDDEEFF", arr[1]["pubkey"].as()); EXPECT_STREQ("", arr[1]["scopes"].as()); EXPECT_STREQ("stale", arr[1]["status"].as()); } 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()); JsonArray arr = parsed["neighbors"].as(); 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(i); neighbors[i].heard_secs_ago = static_cast(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(len), sizeof(buffer)); JsonDocument parsed; ASSERT_FALSE(deserializeJson(parsed, buffer)); JsonArray arr = parsed["neighbors"].as(); 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()); } } // namespace int main(int argc, char** argv) { ::testing::InitGoogleTest(&argc, argv); return RUN_ALL_TESTS(); }