mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-10-31 03:51:58 +01:00
244 lines
8.6 KiB
Ruby
244 lines
8.6 KiB
Ruby
# Copyright © 2025-26 l5yth & contributors
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
# frozen_string_literal: true
|
|
|
|
require_relative "spec_helper"
|
|
|
|
# The Reticulum host's own position (SPEC RP4-RP7). The web app stores and
|
|
# serves positions protocol-neutrally, so these examples pin that the records
|
|
# and rows the Reticulum ingestor posts for its host land like any other
|
|
# protocol's, and that an announce-derived peer stays position-less.
|
|
RSpec.describe "Reticulum host position" do
|
|
let(:app) { Sinatra::Application }
|
|
let(:api_token) { "test-token" }
|
|
let(:auth_headers) do
|
|
{
|
|
"CONTENT_TYPE" => "application/json",
|
|
"HTTP_AUTHORIZATION" => "Bearer #{api_token}",
|
|
}
|
|
end
|
|
let(:now) { Time.now.to_i }
|
|
let(:host_id) { "!27716218" }
|
|
let(:peer_id) { "!aabbccdd" }
|
|
let(:position) do
|
|
{
|
|
"latitude" => 52.5029,
|
|
"longitude" => 13.4042,
|
|
"altitude" => 34.0,
|
|
"time" => now,
|
|
"locationSource" => "LOC_MANUAL",
|
|
}
|
|
end
|
|
|
|
before do
|
|
@original_token = ENV.fetch("API_TOKEN", nil)
|
|
ENV["API_TOKEN"] = api_token
|
|
clear_tables
|
|
PotatoMesh::App::ApiCache.invalidate_all
|
|
end
|
|
|
|
after do
|
|
ENV["API_TOKEN"] = @original_token
|
|
clear_tables
|
|
end
|
|
|
|
# Open a database connection for direct inspection.
|
|
#
|
|
# @param readonly [Boolean] whether to open in read-only mode.
|
|
# @yieldparam db [SQLite3::Database] open database handle.
|
|
# @return [void]
|
|
def with_db(readonly: false)
|
|
db = PotatoMesh::Application.open_database(readonly: readonly)
|
|
db.results_as_hash = true
|
|
yield db
|
|
ensure
|
|
db&.close
|
|
end
|
|
|
|
# Remove all rows from the tables these examples write.
|
|
#
|
|
# @return [void]
|
|
def clear_tables
|
|
with_db do |db|
|
|
%w[positions nodes destinations ingestors].each { |table| db.execute("DELETE FROM #{table}") }
|
|
end
|
|
end
|
|
|
|
# A host destination record as the ingestor posts it at connect and on
|
|
# every self-node report: the announce shape plus the host position.
|
|
#
|
|
# @return [Hash] node record.
|
|
def host_record
|
|
{
|
|
"lastHeard" => now,
|
|
"protocol" => "reticulum",
|
|
"identityHash" => "27716218762cfd2864141ef286c39940",
|
|
"user" => { "shortName" => "2771", "longName" => "Department of Decentralization", "role" => "NODE" },
|
|
"destination" => { "id" => "9c59da5e1516745d74cc908243e0ba2b", "aspect" => "nomadnetwork.node", "role" => "NODE" },
|
|
"position" => position,
|
|
}
|
|
end
|
|
|
|
# The bare host record for a host with nothing announcing (SPEC RP6).
|
|
#
|
|
# @return [Hash] node record.
|
|
def bare_host_record
|
|
{
|
|
"lastHeard" => now,
|
|
"protocol" => "reticulum",
|
|
"user" => { "shortName" => "2771", "longName" => "Reticulum 2771", "role" => "PEER" },
|
|
"position" => position,
|
|
}
|
|
end
|
|
|
|
# An announce-derived peer record: never positioned (SPEC RP4).
|
|
#
|
|
# @return [Hash] node record.
|
|
def peer_record
|
|
{
|
|
"lastHeard" => now,
|
|
"protocol" => "reticulum",
|
|
"user" => { "shortName" => "aabb", "longName" => "Remote Peer", "role" => "PEER" },
|
|
"destination" => { "id" => "c0ffee00222222222222222222222222", "aspect" => "lxmf.delivery", "role" => "PEER" },
|
|
}
|
|
end
|
|
|
|
# The positions row the ingestor posts on each report (SPEC RP5).
|
|
#
|
|
# @return [Hash] positions payload.
|
|
def position_row
|
|
{
|
|
"id" => 4_503_599_627_370_495,
|
|
"rx_time" => now,
|
|
"rx_iso" => Time.at(now).utc.iso8601,
|
|
"node_id" => host_id,
|
|
"node_num" => 0x27716218,
|
|
"from_id" => host_id,
|
|
"latitude" => 52.5029,
|
|
"longitude" => 13.4042,
|
|
"altitude" => 34.0,
|
|
"position_time" => now,
|
|
"location_source" => "LOC_MANUAL",
|
|
"ingestor" => host_id,
|
|
"protocol" => "reticulum",
|
|
}
|
|
end
|
|
|
|
# POST node records the way the ingestor batches them.
|
|
#
|
|
# @param records [Hash{String => Hash}] node id to node record.
|
|
# @return [Rack::MockResponse] the response.
|
|
def post_nodes(records)
|
|
post "/api/nodes", records.merge("ingestor" => host_id, "protocol" => "reticulum").to_json, auth_headers
|
|
last_response
|
|
end
|
|
|
|
# POST the host's positions row.
|
|
#
|
|
# @return [Rack::MockResponse] the response.
|
|
def post_position_row
|
|
post "/api/positions", position_row.to_json, auth_headers
|
|
last_response
|
|
end
|
|
|
|
describe "the host node record" do
|
|
it "stores the position on the host and none on the peer" do
|
|
expect(post_nodes(host_id => host_record, peer_id => peer_record).status).to eq(201)
|
|
|
|
with_db(readonly: true) do |db|
|
|
host = db.get_first_row("SELECT * FROM nodes WHERE node_id = ?", [host_id])
|
|
expect(host.values_at("latitude", "longitude", "altitude")).to eq([52.5029, 13.4042, 34.0])
|
|
expect(host.values_at("position_time", "location_source")).to eq([now, "LOC_MANUAL"])
|
|
expect(host["precision_bits"]).to be_nil
|
|
peer = db.get_first_row("SELECT * FROM nodes WHERE node_id = ?", [peer_id])
|
|
expect(peer.values_at("latitude", "longitude", "position_time")).to eq([nil, nil, nil])
|
|
end
|
|
end
|
|
|
|
it "serves the position on /api/nodes and /api/nodes/:id" do
|
|
post_nodes(host_id => host_record, peer_id => peer_record)
|
|
|
|
get "/api/nodes?protocol=reticulum"
|
|
nodes = JSON.parse(last_response.body).to_h { |node| [node["node_id"], node] }
|
|
expect(nodes[host_id].values_at("latitude", "longitude", "altitude")).to eq([52.5029, 13.4042, 34.0])
|
|
expect(nodes[host_id]["location_source"]).to eq("LOC_MANUAL")
|
|
expect(nodes[peer_id]).not_to have_key("latitude")
|
|
|
|
get "/api/nodes/#{host_id}"
|
|
expect(JSON.parse(last_response.body)["latitude"]).to eq(52.5029)
|
|
end
|
|
|
|
it "stores the bare record under the host's placeholder with no destination" do
|
|
expect(post_nodes(host_id => bare_host_record).status).to eq(201)
|
|
|
|
with_db(readonly: true) do |db|
|
|
host = db.get_first_row("SELECT * FROM nodes WHERE node_id = ?", [host_id])
|
|
expect(host.values_at("long_name", "short_name", "protocol")).to eq(["Reticulum 2771", "2771", "reticulum"])
|
|
expect(host.values_at("latitude", "longitude")).to eq([52.5029, 13.4042])
|
|
expect(db.get_first_value("SELECT COUNT(*) FROM destinations")).to eq(0)
|
|
end
|
|
|
|
# The node API fills a missing role with Meshtastic's CLIENT, which is
|
|
# why the bare record carries Reticulum's base role (SPEC RA9).
|
|
get "/api/nodes/#{host_id}"
|
|
expect(JSON.parse(last_response.body)["role"]).to eq("PEER")
|
|
end
|
|
|
|
it "keeps a stored position when a later record carries none" do
|
|
post_nodes(host_id => host_record)
|
|
later = host_record.reject { |key, _| key == "position" }.merge("lastHeard" => now + 1)
|
|
post_nodes(host_id => later)
|
|
|
|
with_db(readonly: true) do |db|
|
|
expect(db.get_first_value("SELECT latitude FROM nodes WHERE node_id = ?", [host_id])).to eq(52.5029)
|
|
end
|
|
end
|
|
end
|
|
|
|
describe "the host positions row" do
|
|
it "is stored and served under the reticulum protocol" do
|
|
post_nodes(host_id => host_record)
|
|
expect(post_position_row.status).to eq(201)
|
|
|
|
get "/api/positions?protocol=reticulum"
|
|
rows = JSON.parse(last_response.body)
|
|
expect(rows.length).to eq(1)
|
|
expect(rows.first.values_at("node_id", "protocol", "latitude", "longitude", "altitude", "location_source")).to eq([host_id, "reticulum", 52.5029, 13.4042, 34.0, "LOC_MANUAL"])
|
|
expect(rows.first["id"]).to eq(4_503_599_627_370_495)
|
|
end
|
|
|
|
it "counts in the reticulum telemetry umbrella of /api/stats (amends S3)" do
|
|
post_nodes(host_id => host_record)
|
|
post_position_row
|
|
|
|
get "/api/stats"
|
|
stats = JSON.parse(last_response.body)
|
|
expect(stats["reticulum"]["telemetry"]["hour"]).to eq(1)
|
|
expect(stats["reticulum"]["messages"]["hour"]).to eq(0)
|
|
end
|
|
|
|
it "lands first without harm: the record that follows completes the node" do
|
|
expect(post_position_row.status).to eq(201)
|
|
post_nodes(host_id => bare_host_record)
|
|
|
|
with_db(readonly: true) do |db|
|
|
host = db.get_first_row("SELECT * FROM nodes WHERE node_id = ?", [host_id])
|
|
expect(host.values_at("protocol", "short_name", "long_name", "role")).to eq(["reticulum", "2771", "Reticulum 2771", "PEER"])
|
|
expect(host.values_at("latitude", "longitude")).to eq([52.5029, 13.4042])
|
|
end
|
|
end
|
|
end
|
|
end
|