From 2b6b44a31d6df97b04a859100f3e41f6b78d1833 Mon Sep 17 00:00:00 2001 From: l5y <220195275+l5yth@users.noreply.github.com> Date: Tue, 16 Sep 2025 09:29:31 +0200 Subject: [PATCH] Add integration specs for node and message APIs (#76) --- web/spec/app_spec.rb | 328 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 328 insertions(+) diff --git a/web/spec/app_spec.rb b/web/spec/app_spec.rb index 4a7af01..1ba3790 100644 --- a/web/spec/app_spec.rb +++ b/web/spec/app_spec.rb @@ -2,10 +2,147 @@ require "spec_helper" require "sqlite3" +require "json" +require "time" RSpec.describe "Potato Mesh Sinatra app" do let(:app) { Sinatra::Application } + def fixture_path(name) + File.expand_path("../../test/#{name}", __dir__) + end + + def with_db(readonly: false) + db = SQLite3::Database.new(DB_PATH, readonly: readonly) + yield db + ensure + db&.close + end + + def clear_database + with_db do |db| + db.execute("DELETE FROM messages") + db.execute("DELETE FROM nodes") + end + end + + def reject_nil_values(hash) + hash.reject { |_, value| value.nil? } + end + + def build_node_payload(node) + payload = { + "user" => reject_nil_values( + "shortName" => node["short_name"], + "longName" => node["long_name"], + "hwModel" => node["hw_model"], + "role" => node["role"], + ), + "hwModel" => node["hw_model"], + "lastHeard" => node["last_heard"], + "snr" => node["snr"], + } + + metrics = reject_nil_values( + "batteryLevel" => node["battery_level"], + "voltage" => node["voltage"], + "channelUtilization" => node["channel_utilization"], + "airUtilTx" => node["air_util_tx"], + "uptimeSeconds" => node["uptime_seconds"], + ) + payload["deviceMetrics"] = metrics unless metrics.empty? + + position = reject_nil_values( + "time" => node["position_time"], + "latitude" => node["latitude"], + "longitude" => node["longitude"], + "altitude" => node["altitude"], + ) + payload["position"] = position unless position.empty? + + payload + end + + def expected_last_heard(node) + [node["last_heard"], node["position_time"]].compact.max + end + + def expected_node_row(node) + final_last = expected_last_heard(node) + { + "node_id" => node["node_id"], + "short_name" => node["short_name"], + "long_name" => node["long_name"], + "hw_model" => node["hw_model"], + "role" => node["role"] || "CLIENT", + "snr" => node["snr"], + "battery_level" => node["battery_level"], + "voltage" => node["voltage"], + "last_heard" => final_last, + "first_heard" => final_last, + "uptime_seconds" => node["uptime_seconds"], + "channel_utilization" => node["channel_utilization"], + "air_util_tx" => node["air_util_tx"], + "position_time" => node["position_time"], + "latitude" => node["latitude"], + "longitude" => node["longitude"], + "altitude" => node["altitude"], + } + end + + def expect_same_value(actual, expected, tolerance: 1e-6) + if expected.nil? + expect(actual).to be_nil + elsif expected.is_a?(Float) + expect(actual).to be_within(tolerance).of(expected) + else + expect(actual).to eq(expected) + end + end + + def import_nodes_fixture + nodes_fixture.each do |node| + payload = { node["node_id"] => build_node_payload(node) } + post "/api/nodes", payload.to_json, auth_headers + expect(last_response).to be_ok + expect(JSON.parse(last_response.body)).to eq("status" => "ok") + end + end + + def import_messages_fixture + messages_fixture.each do |message| + payload = message.reject { |key, _| key == "node" } + post "/api/messages", payload.to_json, auth_headers + expect(last_response).to be_ok + expect(JSON.parse(last_response.body)).to eq("status" => "ok") + end + end + + let(:api_token) { "spec-token" } + let(:auth_headers) do + { + "CONTENT_TYPE" => "application/json", + "HTTP_AUTHORIZATION" => "Bearer #{api_token}", + } + end + let(:nodes_fixture) { JSON.parse(File.read(fixture_path("nodes.json"))) } + let(:messages_fixture) { JSON.parse(File.read(fixture_path("messages.json"))) } + let(:reference_time) do + latest = nodes_fixture.map { |node| node["last_heard"] }.compact.max + Time.at((latest || Time.now.to_i) + 1000) + end + + before do + @original_token = ENV["API_TOKEN"] + ENV["API_TOKEN"] = api_token + allow(Time).to receive(:now).and_return(reference_time) + clear_database + end + + after do + ENV["API_TOKEN"] = @original_token + end + describe "GET /" do it "responds successfully" do get "/" @@ -26,4 +163,195 @@ RSpec.describe "Potato Mesh Sinatra app" do db&.close end end + + describe "POST /api/nodes" do + it "imports nodes from fixture data into the database" do + import_nodes_fixture + + expected_nodes = nodes_fixture.map do |node| + [node["node_id"], expected_node_row(node)] + end.to_h + + with_db(readonly: true) do |db| + db.results_as_hash = true + rows = db.execute(<<~SQL) + SELECT node_id, short_name, long_name, hw_model, role, snr, + battery_level, voltage, last_heard, first_heard, + uptime_seconds, channel_utilization, air_util_tx, + position_time, latitude, longitude, altitude + FROM nodes + ORDER BY node_id + SQL + + expect(rows.size).to eq(expected_nodes.size) + + rows.each do |row| + expected = expected_nodes.fetch(row["node_id"]) + expect(row["short_name"]).to eq(expected["short_name"]) + expect(row["long_name"]).to eq(expected["long_name"]) + expect(row["hw_model"]).to eq(expected["hw_model"]) + expect(row["role"]).to eq(expected["role"]) + expect_same_value(row["snr"], expected["snr"]) + expect_same_value(row["battery_level"], expected["battery_level"]) + expect_same_value(row["voltage"], expected["voltage"]) + expect(row["last_heard"]).to eq(expected["last_heard"]) + expect(row["first_heard"]).to eq(expected["first_heard"]) + expect_same_value(row["uptime_seconds"], expected["uptime_seconds"]) + expect_same_value(row["channel_utilization"], expected["channel_utilization"]) + expect_same_value(row["air_util_tx"], expected["air_util_tx"]) + expect_same_value(row["position_time"], expected["position_time"]) + expect_same_value(row["latitude"], expected["latitude"]) + expect_same_value(row["longitude"], expected["longitude"]) + expect_same_value(row["altitude"], expected["altitude"]) + end + end + end + end + + describe "POST /api/messages" do + it "persists messages from fixture data" do + import_nodes_fixture + import_messages_fixture + + expected_messages = messages_fixture.map do |message| + [message["id"], message.reject { |key, _| key == "node" }] + end.to_h + + with_db(readonly: true) do |db| + db.results_as_hash = true + rows = db.execute(<<~SQL) + SELECT id, rx_time, rx_iso, from_id, to_id, channel, + portnum, text, snr, rssi, hop_limit + FROM messages + ORDER BY id + SQL + + expect(rows.size).to eq(expected_messages.size) + + rows.each do |row| + expected = expected_messages.fetch(row["id"]) + expect(row["rx_time"]).to eq(expected["rx_time"]) + expect(row["rx_iso"]).to eq(expected["rx_iso"]) + expect(row["from_id"]).to eq(expected["from_id"]) + expect(row["to_id"]).to eq(expected["to_id"]) + expect(row["channel"]).to eq(expected["channel"]) + expect(row["portnum"]).to eq(expected["portnum"]) + expect(row["text"]).to eq(expected["text"]) + expect_same_value(row["snr"], expected["snr"]) + expect(row["rssi"]).to eq(expected["rssi"]) + expect(row["hop_limit"]).to eq(expected["hop_limit"]) + end + end + end + end + + describe "GET /api/nodes" do + it "returns the stored nodes with derived timestamps" do + import_nodes_fixture + + get "/api/nodes" + expect(last_response).to be_ok + + actual = JSON.parse(last_response.body) + expect(actual.size).to eq(nodes_fixture.size) + + actual_by_id = actual.each_with_object({}) do |row, acc| + acc[row["node_id"]] = row + end + + nodes_fixture.each do |node| + expected = expected_node_row(node) + actual_row = actual_by_id.fetch(node["node_id"]) + + expect(actual_row["short_name"]).to eq(expected["short_name"]) + expect(actual_row["long_name"]).to eq(expected["long_name"]) + expect(actual_row["hw_model"]).to eq(expected["hw_model"]) + expect(actual_row["role"]).to eq(expected["role"]) + expect_same_value(actual_row["snr"], expected["snr"]) + expect_same_value(actual_row["battery_level"], expected["battery_level"]) + expect_same_value(actual_row["voltage"], expected["voltage"]) + expect(actual_row["last_heard"]).to eq(expected["last_heard"]) + expect(actual_row["first_heard"]).to eq(expected["first_heard"]) + expect_same_value(actual_row["uptime_seconds"], expected["uptime_seconds"]) + expect_same_value(actual_row["channel_utilization"], expected["channel_utilization"]) + expect_same_value(actual_row["air_util_tx"], expected["air_util_tx"]) + expect_same_value(actual_row["position_time"], expected["position_time"]) + expect_same_value(actual_row["latitude"], expected["latitude"]) + expect_same_value(actual_row["longitude"], expected["longitude"]) + expect_same_value(actual_row["altitude"], expected["altitude"]) + + if expected["last_heard"] + expected_last_seen_iso = Time.at(expected["last_heard"]).utc.iso8601 + expect(actual_row["last_seen_iso"]).to eq(expected_last_seen_iso) + else + expect(actual_row["last_seen_iso"]).to be_nil + end + + if node["position_time"] + expected_pos_iso = Time.at(node["position_time"]).utc.iso8601 + expect(actual_row["pos_time_iso"]).to eq(expected_pos_iso) + else + expect(actual_row).not_to have_key("pos_time_iso") + end + end + end + end + + describe "GET /api/messages" do + it "returns the stored messages along with joined node data" do + import_nodes_fixture + import_messages_fixture + + get "/api/messages" + expect(last_response).to be_ok + + actual = JSON.parse(last_response.body) + expect(actual.size).to eq(messages_fixture.size) + + actual_by_id = actual.each_with_object({}) do |row, acc| + acc[row["id"]] = row + end + + nodes_by_id = nodes_fixture.each_with_object({}) do |node, acc| + acc[node["node_id"]] = expected_node_row(node) + end + + messages_fixture.each do |message| + expected = message.reject { |key, _| key == "node" } + actual_row = actual_by_id.fetch(message["id"]) + + expect(actual_row["rx_time"]).to eq(expected["rx_time"]) + expect(actual_row["rx_iso"]).to eq(expected["rx_iso"]) + expect(actual_row["from_id"]).to eq(expected["from_id"]) + expect(actual_row["to_id"]).to eq(expected["to_id"]) + expect(actual_row["channel"]).to eq(expected["channel"]) + expect(actual_row["portnum"]).to eq(expected["portnum"]) + expect(actual_row["text"]).to eq(expected["text"]) + expect_same_value(actual_row["snr"], expected["snr"]) + expect(actual_row["rssi"]).to eq(expected["rssi"]) + expect(actual_row["hop_limit"]).to eq(expected["hop_limit"]) + + if expected["from_id"] + node_expected = nodes_by_id.fetch(expected["from_id"]) + node_actual = actual_row.fetch("node") + + expect(node_actual["node_id"]).to eq(node_expected["node_id"]) + expect(node_actual["short_name"]).to eq(node_expected["short_name"]) + expect(node_actual["long_name"]).to eq(node_expected["long_name"]) + expect(node_actual["role"]).to eq(node_expected["role"]) + expect_same_value(node_actual["snr"], node_expected["snr"]) + expect_same_value(node_actual["battery_level"], node_expected["battery_level"]) + expect_same_value(node_actual["voltage"], node_expected["voltage"]) + expect(node_actual["last_heard"]).to eq(node_expected["last_heard"]) + expect(node_actual["first_heard"]).to eq(node_expected["first_heard"]) + expect_same_value(node_actual["latitude"], node_expected["latitude"]) + expect_same_value(node_actual["longitude"], node_expected["longitude"]) + expect_same_value(node_actual["altitude"], node_expected["altitude"]) + else + expect(actual_row["node"]).to be_a(Hash) + expect(actual_row["node"]["node_id"]).to be_nil + end + end + end + end end