Files
potato-mesh/web/spec/queries_spec.rb
T
l5y 5e0363a0ec web: breaking change on stats api (#801)
* web: breaking change on stats api

* address review comments
2026-06-21 13:41:41 +02:00

1283 lines
48 KiB
Ruby
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 "spec_helper"
require "sqlite3"
RSpec.describe PotatoMesh::App::Queries do
# Build a lightweight host class that mixes in the module under test plus
# the helpers required by several query helpers (coerce_integer, string_or_nil,
# canonical_node_parts, etc.).
let(:harness_class) do
Class.new do
include PotatoMesh::App::Queries
include PotatoMesh::App::Helpers
include PotatoMesh::App::DataProcessing
# Stub private_mode? so tests do not need env configuration.
def private_mode?
false
end
# Stub prom_report_ids so tests do not depend on prometheus env.
def prom_report_ids
[]
end
# No-op for debug_log calls inside query helpers.
def debug_log(message, **); end
# No-op for warn_log calls inside query helpers.
def warn_log(message, **); end
# Expose the current db path for tests that need to open a handle.
def open_database(readonly: false)
db = SQLite3::Database.new(PotatoMesh::Config.db_path, readonly: readonly)
db.results_as_hash = true
db.busy_timeout = PotatoMesh::Config.db_busy_timeout_ms
db
end
def normalize_node_id(db, node_ref)
return nil unless node_ref
parts = canonical_node_parts(node_ref)
parts ? parts[0] : nil
end
def with_busy_retry
yield
end
def update_prometheus_metrics(*); end
def resolve_protocol(_db, _ingestor, cache: nil)
"meshtastic"
end
end
end
subject(:queries) { harness_class.new }
# ---------------------------------------------------------------------------
# compact_api_row
# ---------------------------------------------------------------------------
describe "#compact_api_row" do
it "returns an empty hash for a non-Hash argument" do
expect(queries.compact_api_row(nil)).to eq({})
expect(queries.compact_api_row("string")).to eq({})
expect(queries.compact_api_row(42)).to eq({})
end
it "removes nil values" do
expect(queries.compact_api_row({ "a" => nil, "b" => "x" })).to eq({ "b" => "x" })
end
it "removes blank string values (whitespace-only)" do
expect(queries.compact_api_row({ "a" => " ", "b" => "ok" })).to eq({ "b" => "ok" })
end
it "removes empty string values" do
expect(queries.compact_api_row({ "a" => "", "b" => "y" })).to eq({ "b" => "y" })
end
it "retains non-blank string values" do
result = queries.compact_api_row({ "k" => " value " })
expect(result["k"]).to eq(" value ")
end
it "retains zero numeric values" do
expect(queries.compact_api_row({ "count" => 0 })).to eq({ "count" => 0 })
end
it "removes integer keys (SQLite row-index artifacts)" do
row = { 0 => "x", "name" => "node" }
expect(queries.compact_api_row(row)).to eq({ "name" => "node" })
end
it "removes empty arrays" do
expect(queries.compact_api_row({ "hops" => [], "id" => 1 })).to eq({ "id" => 1 })
end
it "retains non-empty arrays" do
row = { "hops" => [1, 2] }
expect(queries.compact_api_row(row)).to eq({ "hops" => [1, 2] })
end
it "retains false booleans" do
expect(queries.compact_api_row({ "flag" => false })).to eq({ "flag" => false })
end
end
# ---------------------------------------------------------------------------
# nil_if_zero
# ---------------------------------------------------------------------------
describe "#nil_if_zero" do
it "returns nil when value is integer zero" do
expect(queries.nil_if_zero(0)).to be_nil
end
it "returns nil when value is float zero" do
expect(queries.nil_if_zero(0.0)).to be_nil
end
it "returns the value unchanged for non-zero integers" do
expect(queries.nil_if_zero(5)).to eq(5)
end
it "returns the value unchanged for non-zero floats" do
expect(queries.nil_if_zero(3.14)).to eq(3.14)
end
it "passes nil through unchanged" do
expect(queries.nil_if_zero(nil)).to be_nil
end
it "passes strings through unchanged (no zero? method behaviour)" do
expect(queries.nil_if_zero("0")).to eq("0")
end
end
# ---------------------------------------------------------------------------
# coerce_positive_or_nil — read-side guard against truthy-zero ISO emission
# (issue #782). Every branch is exercised: nil, non-positive, future-clamped,
# numeric-string, non-numeric, and the happy path.
# ---------------------------------------------------------------------------
describe "#coerce_positive_or_nil" do
it "returns nil when value is nil" do
expect(queries.coerce_positive_or_nil(nil)).to be_nil
end
it "returns nil for zero (the truthy-zero case)" do
expect(queries.coerce_positive_or_nil(0)).to be_nil
end
it "returns nil for negative integers" do
expect(queries.coerce_positive_or_nil(-1)).to be_nil
end
it "returns the integer for positive integers" do
expect(queries.coerce_positive_or_nil(42)).to eq(42)
end
it "coerces numeric strings to integers" do
expect(queries.coerce_positive_or_nil("123")).to eq(123)
end
it "returns nil for the literal string '0'" do
expect(queries.coerce_positive_or_nil("0")).to be_nil
end
it "returns nil for non-numeric strings" do
expect(queries.coerce_positive_or_nil("not-a-number")).to be_nil
end
it "returns nil for values exceeding the ceiling" do
expect(queries.coerce_positive_or_nil(100, ceiling: 50)).to be_nil
end
it "returns the value when equal to the ceiling (inclusive)" do
expect(queries.coerce_positive_or_nil(50, ceiling: 50)).to eq(50)
end
it "ignores the ceiling when nil" do
expect(queries.coerce_positive_or_nil(1_000_000)).to eq(1_000_000)
end
end
# ---------------------------------------------------------------------------
# append_protocol_filter
# ---------------------------------------------------------------------------
describe "#append_protocol_filter" do
it "appends a clause and param when protocol is given" do
clauses = []
params = []
queries.append_protocol_filter(clauses, params, "meshtastic")
expect(clauses).to eq(["protocol = ?"])
expect(params).to eq(["meshtastic"])
end
it "is a no-op when protocol is nil" do
clauses = []
params = []
queries.append_protocol_filter(clauses, params, nil)
expect(clauses).to be_empty
expect(params).to be_empty
end
it "includes a table alias prefix when provided" do
clauses = []
params = []
queries.append_protocol_filter(clauses, params, "meshcore", table_alias: "m")
expect(clauses).to eq(["m.protocol = ?"])
expect(params).to eq(["meshcore"])
end
end
# ---------------------------------------------------------------------------
# coerce_query_limit
# ---------------------------------------------------------------------------
describe "#coerce_query_limit" do
it "returns the value when within range" do
expect(queries.coerce_query_limit(50)).to eq(50)
end
it "caps the value at MAX_QUERY_LIMIT" do
expect(queries.coerce_query_limit(5000)).to eq(PotatoMesh::App::Queries::MAX_QUERY_LIMIT)
end
it "returns the default when nil is supplied" do
expect(queries.coerce_query_limit(nil)).to eq(200)
end
it "returns the default for a non-numeric string" do
expect(queries.coerce_query_limit("abc")).to eq(200)
end
it "accepts an integer given as string" do
expect(queries.coerce_query_limit("100")).to eq(100)
end
it "returns the default for zero or negative values" do
expect(queries.coerce_query_limit(0)).to eq(200)
expect(queries.coerce_query_limit(-10)).to eq(200)
end
it "honours a custom default" do
expect(queries.coerce_query_limit(nil, default: 42)).to eq(42)
end
end
# ---------------------------------------------------------------------------
# normalize_since_threshold
# ---------------------------------------------------------------------------
describe "#normalize_since_threshold" do
it "passes a valid integer through" do
t = Time.now.to_i - 3600
expect(queries.normalize_since_threshold(t)).to eq(t)
end
it "returns zero when nil is supplied and floor is 0" do
expect(queries.normalize_since_threshold(nil)).to eq(0)
end
it "returns zero for invalid string input" do
expect(queries.normalize_since_threshold("bad")).to eq(0)
end
it "applies the floor when the computed value is below it" do
floor = 1_000_000
expect(queries.normalize_since_threshold(0, floor: floor)).to eq(floor)
end
it "clamps negative values to zero before comparing floor" do
expect(queries.normalize_since_threshold(-99, floor: 0)).to eq(0)
end
end
# ---------------------------------------------------------------------------
# node_reference_tokens
# ---------------------------------------------------------------------------
describe "#node_reference_tokens" do
it "handles an !hex string" do
result = queries.node_reference_tokens("!aabbccdd")
expect(result[:string_values]).to include("!aabbccdd")
expect(result[:numeric_values]).to include(0xaabbccdd)
end
it "handles a 0x-prefixed hex string" do
result = queries.node_reference_tokens("0xaabbccdd")
expect(result[:numeric_values]).to include(0xaabbccdd)
end
it "handles a decimal integer" do
result = queries.node_reference_tokens(12345)
expect(result[:numeric_values]).to include(12345)
expect(result[:string_values]).to include("12345")
end
it "returns empty collections for nil" do
result = queries.node_reference_tokens(nil)
expect(result[:string_values]).to be_empty
expect(result[:numeric_values]).to be_empty
end
it "handles a plain decimal string" do
result = queries.node_reference_tokens("67890")
expect(result[:numeric_values]).to include(67890)
end
end
# ---------------------------------------------------------------------------
# node_lookup_clause
# ---------------------------------------------------------------------------
describe "#node_lookup_clause" do
it "returns nil when no tokens match any column" do
# A nil node_ref produces empty tokens → nil clause
result = queries.node_lookup_clause(nil, string_columns: ["node_id"])
expect(result).to be_nil
end
it "returns a clause and params for a single token" do
clause, params = queries.node_lookup_clause("!aabbccdd", string_columns: ["node_id"])
expect(clause).to include("node_id IN (")
expect(params).not_to be_empty
end
it "includes multiple columns joined with OR" do
clause, _params = queries.node_lookup_clause(
"!aabbccdd",
string_columns: ["from_id", "to_id"],
)
expect(clause).to include("from_id IN (")
expect(clause).to include("to_id IN (")
expect(clause).to include(" OR ")
end
it "includes numeric column clauses when numeric tokens are present" do
clause, params = queries.node_lookup_clause(
12345,
string_columns: [],
numeric_columns: ["num"],
)
expect(clause).to include("num IN (")
expect(params).to include(12345)
end
end
# ---------------------------------------------------------------------------
# sanitize_zero_invalid_metric
# ---------------------------------------------------------------------------
describe "#sanitize_zero_invalid_metric" do
it "returns nil when value is zero for a ZERO_INVALID column" do
expect(queries.sanitize_zero_invalid_metric("battery_level", 0.0)).to be_nil
expect(queries.sanitize_zero_invalid_metric("voltage", 0.0)).to be_nil
end
it "returns the value when non-zero for a ZERO_INVALID column" do
expect(queries.sanitize_zero_invalid_metric("battery_level", 80.0)).to eq(80.0)
end
it "keeps zero for a column not in ZERO_INVALID list" do
expect(queries.sanitize_zero_invalid_metric("temperature", 0.0)).to eq(0.0)
end
it "passes nil through unchanged" do
expect(queries.sanitize_zero_invalid_metric("battery_level", nil)).to be_nil
end
end
# ---------------------------------------------------------------------------
# telemetry_aggregate_source
# ---------------------------------------------------------------------------
describe "#telemetry_aggregate_source" do
it "wraps zero-invalid columns in NULLIF" do
expect(queries.telemetry_aggregate_source("battery_level")).to eq("NULLIF(battery_level, 0)")
expect(queries.telemetry_aggregate_source("voltage")).to eq("NULLIF(voltage, 0)")
end
it "returns the column name unchanged for valid-zero columns" do
expect(queries.telemetry_aggregate_source("temperature")).to eq("temperature")
end
end
# ---------------------------------------------------------------------------
# Database-backed query methods (query_nodes, query_messages, etc.)
# ---------------------------------------------------------------------------
around do |example|
Dir.mktmpdir("queries-spec-") do |dir|
db_path = File.join(dir, "mesh.db")
RSpec::Mocks.with_temporary_scope do
allow(PotatoMesh::Config).to receive(:db_path).and_return(db_path)
allow(PotatoMesh::Config).to receive(:db_busy_timeout_ms).and_return(5000)
allow(PotatoMesh::Config).to receive(:week_seconds).and_return(604_800)
allow(PotatoMesh::Config).to receive(:four_weeks_seconds).and_return(604_800)
allow(PotatoMesh::Config).to receive(:debug?).and_return(false)
# Initialise schema so query methods can execute real SQL.
db_helper = Object.new.extend(PotatoMesh::App::Database)
db_helper.init_db
db_helper.ensure_schema_upgrades
example.run
end
end
end
let(:now) { Time.now.to_i }
# Convenience helper: open an in-spec db handle.
def with_db
db = SQLite3::Database.new(PotatoMesh::Config.db_path)
db.results_as_hash = true
yield db
ensure
db&.close
end
describe "#query_nodes" do
before do
with_db do |db|
db.execute(
"INSERT INTO nodes(node_id, num, short_name, last_heard, first_heard, role) VALUES (?,?,?,?,?,?)",
["!aabbccdd", 0xaabbccdd, "TEST", now, now, "CLIENT"],
)
end
end
it "returns nodes from the database" do
rows = queries.query_nodes(10)
expect(rows).to be_an(Array)
expect(rows.length).to be >= 1
ids = rows.map { |r| r["node_id"] }
expect(ids).to include("!aabbccdd")
end
it "filters by node_id when node_ref is supplied" do
with_db do |db|
db.execute(
"INSERT INTO nodes(node_id, num, short_name, last_heard, first_heard, role) VALUES (?,?,?,?,?,?)",
["!11223344", 0x11223344, "OTHER", now, now, "CLIENT"],
)
end
rows = queries.query_nodes(10, node_ref: "!aabbccdd")
ids = rows.map { |r| r["node_id"] }
expect(ids).to include("!aabbccdd")
expect(ids).not_to include("!11223344")
end
it "respects since_time filter" do
rows = queries.query_nodes(10, since: now + 9999)
expect(rows).to be_empty
end
# Regression for issue #782: a stored `position_time = 0` historically
# bypassed the `if pt` ISO guard because Ruby treats `0` as truthy, leaking
# "1970-01-01T00:00:00Z" into API responses. `coerce_positive_or_nil`
# normalises the value to nil so neither the column nor its ISO sibling
# surface in the row.
it "strips position_time = 0 sentinels and emits no epoch ISO" do
with_db do |db|
db.execute(
"UPDATE nodes SET position_time = ?, latitude = ?, longitude = ? WHERE node_id = ?",
[0, 0.0, 0.0, "!aabbccdd"],
)
end
rows = queries.query_nodes(10, node_ref: "!aabbccdd")
row = rows.find { |r| r["node_id"] == "!aabbccdd" }
expect(row).not_to be_nil
expect(row).not_to have_key("position_time")
expect(row).not_to have_key("pos_time_iso")
expect(row.values).not_to include("1970-01-01T00:00:00Z")
end
it "strips last_heard = 0 sentinels and emits no epoch ISO" do
with_db do |db|
db.execute(
"UPDATE nodes SET last_heard = ? WHERE node_id = ?",
[0, "!aabbccdd"],
)
end
rows = queries.query_nodes(10, node_ref: "!aabbccdd")
row = rows.find { |r| r["node_id"] == "!aabbccdd" }
expect(row).not_to be_nil
expect(row).not_to have_key("last_heard")
expect(row).not_to have_key("last_seen_iso")
expect(row.values).not_to include("1970-01-01T00:00:00Z")
end
context "COMPANION short name enrichment" do
it "derives a two-initial short name for a COMPANION node with a two-word long name" do
with_db do |db|
db.execute(
"INSERT INTO nodes(node_id, num, short_name, long_name, last_heard, first_heard, role) " \
"VALUES (?,?,?,?,?,?,?)",
["!cc000001", 0xcc000001, "CX", "Alice Bob", now, now, "COMPANION"],
)
end
rows = queries.query_nodes(10, node_ref: "!cc000001")
row = rows.find { |r| r["node_id"] == "!cc000001" }
expect(row).not_to be_nil
expect(row["short_name"]).to eq(" AB ")
end
it "uses the raw DB short name for a COMPANION node with a single-word long name" do
with_db do |db|
db.execute(
"INSERT INTO nodes(node_id, num, short_name, long_name, last_heard, first_heard, role) " \
"VALUES (?,?,?,?,?,?,?)",
["!cc000002", 0xcc000002, "CX", "Zigzag", now, now, "COMPANION"],
)
end
rows = queries.query_nodes(10, node_ref: "!cc000002")
row = rows.find { |r| r["node_id"] == "!cc000002" }
expect(row["short_name"]).to eq("CX")
end
it "falls back to first four hex chars of the node ID when DB short name is empty for COMPANION" do
with_db do |db|
db.execute(
"INSERT INTO nodes(node_id, num, short_name, long_name, last_heard, first_heard, role) " \
"VALUES (?,?,?,?,?,?,?)",
["!cc000009", 0xcc000009, "", "Feierabend", now, now, "COMPANION"],
)
end
rows = queries.query_nodes(10, node_ref: "!cc000009")
row = rows.find { |r| r["node_id"] == "!cc000009" }
expect(row["short_name"]).to eq("cc00")
end
it "derives an emoji short name for a COMPANION node whose long name contains an emoji" do
with_db do |db|
db.execute(
"INSERT INTO nodes(node_id, num, short_name, long_name, last_heard, first_heard, role) " \
"VALUES (?,?,?,?,?,?,?)",
["!cc000003", 0xcc000003, "CX", "Node \u{1F600}", now, now, "COMPANION"],
)
end
rows = queries.query_nodes(10, node_ref: "!cc000003")
row = rows.find { |r| r["node_id"] == "!cc000003" }
expect(row["short_name"]).to eq(" \u{1F600} ")
end
it "does not overwrite short_name when long_name is blank for a COMPANION node" do
with_db do |db|
db.execute(
"INSERT INTO nodes(node_id, num, short_name, long_name, last_heard, first_heard, role) " \
"VALUES (?,?,?,?,?,?,?)",
["!cc000004", 0xcc000004, "CX", "", now, now, "COMPANION"],
)
end
rows = queries.query_nodes(10, node_ref: "!cc000004")
row = rows.find { |r| r["node_id"] == "!cc000004" }
# blank long_name → nil derived → original short_name preserved by compact_api_row
expect(row["short_name"]).to eq("CX")
end
it "leaves the short_name unchanged for a CLIENT node with a multi-word long name" do
with_db do |db|
db.execute(
"INSERT INTO nodes(node_id, num, short_name, long_name, last_heard, first_heard, role) " \
"VALUES (?,?,?,?,?,?,?)",
["!cc000005", 0xcc000005, "XY", "Alice Bob", now, now, "CLIENT"],
)
end
rows = queries.query_nodes(10, node_ref: "!cc000005")
row = rows.find { |r| r["node_id"] == "!cc000005" }
expect(row["short_name"]).to eq("XY")
end
it "does not overwrite short_name when long_name is NULL in the DB for a COMPANION node" do
with_db do |db|
db.execute(
"INSERT INTO nodes(node_id, num, short_name, long_name, last_heard, first_heard, role) " \
"VALUES (?,?,?,?,?,?,?)",
["!cc000007", 0xcc000007, "CX", nil, now, now, "COMPANION"],
)
end
rows = queries.query_nodes(10, node_ref: "!cc000007")
row = rows.find { |r| r["node_id"] == "!cc000007" }
expect(row["short_name"]).to eq("CX")
end
it "leaves the short_name unchanged for a node whose role defaults to CLIENT (nil in DB)" do
with_db do |db|
db.execute(
"INSERT INTO nodes(node_id, num, short_name, long_name, last_heard, first_heard, role) " \
"VALUES (?,?,?,?,?,?,?)",
["!cc000006", 0xcc000006, "ZZ", "Alice Bob", now, now, nil],
)
end
rows = queries.query_nodes(10, node_ref: "!cc000006")
row = rows.find { |r| r["node_id"] == "!cc000006" }
expect(row["short_name"]).to eq("ZZ")
end
end
end
describe "#query_messages" do
before do
with_db do |db|
rx_iso = Time.at(now).utc.iso8601
db.execute(
"INSERT INTO messages(id, rx_time, rx_iso, from_id, to_id, channel, text) VALUES (?,?,?,?,?,?,?)",
[1, now, rx_iso, "!aabbccdd", "!ffffffff", 0, "hello"],
)
end
end
it "returns messages" do
rows = queries.query_messages(10)
expect(rows).to be_an(Array)
texts = rows.map { |r| r["text"] }
expect(texts).to include("hello")
end
it "filters by since_time" do
rows = queries.query_messages(10, since: now + 9999)
expect(rows).to be_empty
end
it "filters by node_ref" do
with_db do |db|
rx_iso = Time.at(now).utc.iso8601
db.execute(
"INSERT INTO messages(id, rx_time, rx_iso, from_id, to_id, channel, text) VALUES (?,?,?,?,?,?,?)",
[2, now, rx_iso, "!deadbeef", "!ffffffff", 0, "other message"],
)
end
rows = queries.query_messages(10, node_ref: "!aabbccdd")
texts = rows.map { |r| r["text"] }
expect(texts).to include("hello")
expect(texts).not_to include("other message")
end
it "applies a seven-day floor for bulk queries and twenty-eight days for per-id" do
# Re-stub the windows with their real ratio so the bulk-vs-per-id
# distinction is observable from a single test.
allow(PotatoMesh::Config).to receive(:week_seconds).and_return(7 * 24 * 60 * 60)
allow(PotatoMesh::Config).to receive(:four_weeks_seconds).and_return(28 * 24 * 60 * 60)
stale_rx = now - (7 * 24 * 60 * 60 + 60)
backfillable_rx = now - (28 * 24 * 60 * 60 - 60)
with_db do |db|
rx_iso = Time.at(stale_rx).utc.iso8601
db.execute(
"INSERT INTO messages(id, rx_time, rx_iso, from_id, to_id, channel, text) VALUES (?,?,?,?,?,?,?)",
[101, stale_rx, rx_iso, "!aabbccdd", "!ffffffff", 0, "stale"],
)
db.execute(
"INSERT INTO messages(id, rx_time, rx_iso, from_id, to_id, channel, text) VALUES (?,?,?,?,?,?,?)",
[102, backfillable_rx, Time.at(backfillable_rx).utc.iso8601, "!aabbccdd", "!ffffffff", 0, "backfill"],
)
end
bulk_ids = queries.query_messages(50).map { |r| r["id"] }
expect(bulk_ids).not_to include(101)
expect(bulk_ids).not_to include(102)
scoped_ids = queries.query_messages(50, node_ref: "!aabbccdd").map { |r| r["id"] }
expect(scoped_ids).to include(101)
expect(scoped_ids).to include(102)
end
it "applies an inclusive before cursor and ignores a non-positive one (issue #796)" do
with_db do |db|
[10, 20, 30].each do |offset|
rx = now - offset
db.execute(
"INSERT INTO messages(id, rx_time, rx_iso, from_id, to_id, channel, text) VALUES (?,?,?,?,?,?,?)",
[200 + offset, rx, Time.at(rx).utc.iso8601, "!aabbccdd", "!ffffffff", 0, "m#{offset}"],
)
end
end
# Inclusive ceiling: the row exactly at the cursor stays; newer rows drop.
# This is what lets the client page backward by feeding the oldest rx_time
# of each page as the next cursor without skipping boundary-second rows.
paged = queries.query_messages(10, before: now - 20).map { |r| r["id"] }
expect(paged).to include(220, 230)
expect(paged).not_to include(210) # newer than the cursor
expect(paged).not_to include(1) # base row at `now` is newer than the cursor
# A non-positive cursor is treated as "no cursor" — the default window.
unbounded = queries.query_messages(10, before: 0).map { |r| r["id"] }
expect(unbounded).to include(1, 210, 220, 230)
end
end
describe "#query_telemetry" do
before do
with_db do |db|
rx_iso = Time.at(now).utc.iso8601
db.execute(
"INSERT INTO telemetry(id, rx_time, rx_iso, node_id, telemetry_type) VALUES (?,?,?,?,?)",
[1, now, rx_iso, "!aabbccdd", "device"],
)
end
end
it "returns telemetry rows" do
rows = queries.query_telemetry(10)
expect(rows).to be_an(Array)
expect(rows.length).to be >= 1
end
it "filters by node_ref" do
rows = queries.query_telemetry(10, node_ref: "!aabbccdd")
expect(rows.length).to be >= 1
end
end
describe "#query_positions" do
before do
with_db do |db|
rx_iso = Time.at(now).utc.iso8601
db.execute(
"INSERT INTO positions(id, rx_time, rx_iso, node_id, latitude, longitude) VALUES (?,?,?,?,?,?)",
[1, now, rx_iso, "!aabbccdd", 52.0, 13.0],
)
end
end
it "returns position rows" do
rows = queries.query_positions(10)
expect(rows).to be_an(Array)
expect(rows.length).to be >= 1
end
it "filters by node_ref" do
rows = queries.query_positions(10, node_ref: "!aabbccdd")
expect(rows.length).to be >= 1
end
# Regression for issue #782: legacy rows seeded with `position_time = 0`
# must not surface "1970-01-01T00:00:00Z" via the `position_time_iso`
# output. See `coerce_positive_or_nil` in queries/common.rb.
it "strips position_time = 0 sentinels from position rows" do
with_db do |db|
rx_iso = Time.at(now).utc.iso8601
db.execute(
"INSERT INTO positions(id, rx_time, rx_iso, node_id, latitude, longitude, position_time) " \
"VALUES (?,?,?,?,?,?,?)",
[2, now, rx_iso, "!aabbccdd", 52.0, 13.0, 0],
)
end
row = queries.query_positions(10).find { |r| r["id"] == 2 }
expect(row).not_to be_nil
expect(row).not_to have_key("position_time")
expect(row).not_to have_key("position_time_iso")
expect(row.values).not_to include("1970-01-01T00:00:00Z")
end
end
describe "#query_neighbors" do
before do
with_db do |db|
# neighbors has a composite primary key (node_id, neighbor_id) and
# foreign keys referencing nodes; insert required node rows first.
db.execute(
"INSERT OR IGNORE INTO nodes(node_id, num, last_heard, first_heard, role) VALUES (?,?,?,?,?)",
["!aabbccdd", 0xaabbccdd, now, now, "CLIENT"],
)
db.execute(
"INSERT OR IGNORE INTO nodes(node_id, num, last_heard, first_heard, role) VALUES (?,?,?,?,?)",
["!11223344", 0x11223344, now, now, "CLIENT"],
)
db.execute(
"INSERT INTO neighbors(node_id, neighbor_id, snr, rx_time) VALUES (?,?,?,?)",
["!aabbccdd", "!11223344", 5.0, now],
)
end
end
it "returns neighbor rows" do
rows = queries.query_neighbors(10)
expect(rows).to be_an(Array)
expect(rows.length).to be >= 1
end
end
describe "#query_traces" do
before do
with_db do |db|
rx_iso = Time.at(now).utc.iso8601
db.execute(
"INSERT INTO traces(id, rx_time, rx_iso, src, dest) VALUES (?,?,?,?,?)",
[1, now, rx_iso, 0xaabbccdd, 0x11223344],
)
end
end
it "returns trace rows" do
rows = queries.query_traces(10)
expect(rows).to be_an(Array)
expect(rows.length).to be >= 1
end
end
# ---------------------------------------------------------------------------
# batch_resolve_node_ids
# ---------------------------------------------------------------------------
describe "#batch_resolve_node_ids" do
before do
with_db do |db|
db.execute(
"INSERT INTO nodes(node_id, num, short_name, last_heard, first_heard, role) VALUES (?,?,?,?,?,?)",
["!aabb0001", 0xaabb0001, "N1", now, now, "CLIENT"],
)
db.execute(
"INSERT INTO nodes(node_id, num, short_name, last_heard, first_heard, role) VALUES (?,?,?,?,?,?)",
["!aabb0002", 0xaabb0002, "N2", now, now, "CLIENT"],
)
end
end
it "resolves string node_id references" do
with_db do |db|
result = queries.batch_resolve_node_ids(db, ["!aabb0001", "!aabb0002"])
expect(result["!aabb0001"]).to eq("!aabb0001")
expect(result["!aabb0002"]).to eq("!aabb0002")
end
end
it "resolves numeric references to canonical node_id" do
with_db do |db|
num_str = 0xaabb0001.to_s
result = queries.batch_resolve_node_ids(db, [num_str])
expect(result[num_str]).to eq("!aabb0001")
end
end
it "returns an empty hash for empty input" do
with_db do |db|
expect(queries.batch_resolve_node_ids(db, [])).to eq({})
expect(queries.batch_resolve_node_ids(db, nil)).to eq({})
end
end
it "omits references that cannot be resolved" do
with_db do |db|
result = queries.batch_resolve_node_ids(db, ["!nonexistent"])
expect(result).not_to have_key("!nonexistent")
end
end
end
# ---------------------------------------------------------------------------
# node_lookup_clause with db parameter
# ---------------------------------------------------------------------------
describe "#node_lookup_clause with db" do
before do
with_db do |db|
db.execute(
"INSERT INTO nodes(node_id, num, short_name, last_heard, first_heard, role) VALUES (?,?,?,?,?,?)",
["!deadbeef", 0xdeadbeef, "DB", now, now, "CLIENT"],
)
end
end
it "folds numeric columns into string columns when db is provided" do
with_db do |db|
clause = queries.node_lookup_clause(
"!deadbeef",
string_columns: ["node_id"],
numeric_columns: ["node_num"],
db: db,
)
expect(clause).not_to be_nil
sql_fragment, _params = clause
# When db is provided and numeric values are resolved, the OR with
# node_num should not appear in the SQL.
expect(sql_fragment).not_to include("node_num")
expect(sql_fragment).to include("node_id")
end
end
it "falls back to OR when db is not provided" do
clause = queries.node_lookup_clause(
0xdeadbeef,
string_columns: ["node_id"],
numeric_columns: ["node_num"],
)
expect(clause).not_to be_nil
sql_fragment, _params = clause
expect(sql_fragment).to include("OR")
end
end
# ---------------------------------------------------------------------------
# opt-out helpers (🛑 in long/short name)
# ---------------------------------------------------------------------------
describe "#opt_out_marker_params" do
it "returns the marker twice so each LIKE expression has a bound value" do
params = queries.opt_out_marker_params
expect(params.length).to eq(2)
expect(params).to all(eq(PotatoMesh::Config.node_opt_out_marker))
end
end
describe "#opt_out_self_filter" do
it "is a negation of the OR-of-LIKE name predicate" do
fragment = queries.opt_out_self_filter
expect(fragment).to start_with("NOT ")
expect(fragment).to include("COALESCE(long_name")
expect(fragment).to include("COALESCE(short_name")
end
end
describe "#opt_out_node_id_filter" do
it "wraps the column lookup with NULL passthrough" do
fragment = queries.opt_out_node_id_filter("m.from_id")
expect(fragment).to include("m.from_id IS NULL")
expect(fragment).to include("m.from_id NOT IN")
expect(fragment).to include("SELECT node_id FROM nodes")
end
it "guards the inner subquery against NULL node_id values" do
# SQLite's `NOT IN (subquery returning NULL)` evaluates to UNKNOWN and
# would silently drop every row. The subquery must reject NULL ids.
fragment = queries.opt_out_node_id_filter("from_id")
expect(fragment).to include("node_id IS NOT NULL")
end
it "rejects column identifiers containing unsafe characters" do
expect { queries.opt_out_node_id_filter("from_id; DROP TABLE nodes--") }.to raise_error(ArgumentError, /unsafe column identifier/)
expect { queries.opt_out_node_id_filter("") }.to raise_error(ArgumentError, /unsafe column identifier/)
expect { queries.opt_out_node_id_filter(nil) }.to raise_error(ArgumentError, /unsafe column identifier/)
end
end
describe "#opt_out_node_num_filter" do
it "guards against NULL numeric IDs and skips nodes without num" do
fragment = queries.opt_out_node_num_filter("src")
expect(fragment).to include("src IS NULL")
expect(fragment).to include("src NOT IN")
expect(fragment).to include("num IS NOT NULL")
end
it "rejects column identifiers containing unsafe characters" do
expect { queries.opt_out_node_num_filter("src OR 1=1") }.to raise_error(ArgumentError, /unsafe column identifier/)
end
end
describe "#assert_safe_column_identifier!" do
it "accepts bare identifiers and dotted qualifiers" do
expect(queries.assert_safe_column_identifier!("node_id")).to eq("node_id")
expect(queries.assert_safe_column_identifier!("m.from_id")).to eq("m.from_id")
end
it "rejects anything else" do
["1bad", "a.b.c", "name`", "name with space", nil, 42].each do |bad|
expect { queries.assert_safe_column_identifier!(bad) }.to raise_error(ArgumentError, /unsafe column identifier/)
end
end
end
describe "#append_opt_out_filter" do
it "appends the SQL fragment and its two marker bind values" do
clauses = []
params = []
queries.append_opt_out_filter(clauses, params, queries.opt_out_self_filter)
expect(clauses.length).to eq(1)
expect(params.length).to eq(2)
end
end
describe "#clamp_window_seconds" do
it "returns nil for non-positive or nil input" do
expect(queries.clamp_window_seconds(nil)).to be_nil
expect(queries.clamp_window_seconds(0)).to be_nil
expect(queries.clamp_window_seconds(-1)).to be_nil
end
it "passes positive values through when within the 28-day cap" do
expect(queries.clamp_window_seconds(60)).to eq(60)
end
it "clamps oversized windows to four_weeks_seconds" do
huge = PotatoMesh::Config.four_weeks_seconds * 10
expect(queries.clamp_window_seconds(huge)).to eq(PotatoMesh::Config.four_weeks_seconds)
end
end
describe "opt-out filtering in read queries" do
let(:marker) { PotatoMesh::Config.node_opt_out_marker }
# Seed a visible node and an opted-out node sharing similar telemetry,
# message, position, neighbor, and trace footprints so each query helper
# can be checked for opt-out compliance from one fixture.
before do
with_db do |db|
db.execute(
"INSERT INTO nodes(node_id, num, short_name, long_name, last_heard, first_heard, role) " \
"VALUES (?,?,?,?,?,?,?)",
["!visible0", 0x00bb0001, "VIS", "Visible Node", now, now, "CLIENT"],
)
db.execute(
"INSERT INTO nodes(node_id, num, short_name, long_name, last_heard, first_heard, role) " \
"VALUES (?,?,?,?,?,?,?)",
["!optout01", 0x00bb0002, "OUT", "Hidden #{marker} Node", now, now, "CLIENT"],
)
db.execute(
"INSERT INTO nodes(node_id, num, short_name, long_name, last_heard, first_heard, role) " \
"VALUES (?,?,?,?,?,?,?)",
["!optshort", 0x00bb0003, "S#{marker}X", "Short Marker", now, now, "CLIENT"],
)
rx_iso = Time.at(now).utc.iso8601
db.execute(
"INSERT INTO messages(id, rx_time, rx_iso, from_id, to_id, channel, text) VALUES (?,?,?,?,?,?,?)",
[10, now, rx_iso, "!visible0", "!ffffffff", 0, "from-visible"],
)
db.execute(
"INSERT INTO messages(id, rx_time, rx_iso, from_id, to_id, channel, text) VALUES (?,?,?,?,?,?,?)",
[11, now, rx_iso, "!optout01", "!ffffffff", 0, "from-optout"],
)
db.execute(
"INSERT INTO messages(id, rx_time, rx_iso, from_id, to_id, channel, text) VALUES (?,?,?,?,?,?,?)",
[12, now, rx_iso, "!visible0", "!optout01", 0, "to-optout"],
)
db.execute(
"INSERT INTO positions(id, rx_time, rx_iso, node_id, latitude, longitude) VALUES (?,?,?,?,?,?)",
[10, now, rx_iso, "!visible0", 52.0, 13.0],
)
db.execute(
"INSERT INTO positions(id, rx_time, rx_iso, node_id, latitude, longitude) VALUES (?,?,?,?,?,?)",
[11, now, rx_iso, "!optout01", 53.0, 14.0],
)
db.execute(
"INSERT INTO telemetry(id, rx_time, rx_iso, node_id, telemetry_type) VALUES (?,?,?,?,?)",
[10, now, rx_iso, "!visible0", "device"],
)
db.execute(
"INSERT INTO telemetry(id, rx_time, rx_iso, node_id, telemetry_type) VALUES (?,?,?,?,?)",
[11, now, rx_iso, "!optout01", "device"],
)
db.execute(
"INSERT INTO neighbors(node_id, neighbor_id, snr, rx_time) VALUES (?,?,?,?)",
["!visible0", "!optout01", 5.0, now],
)
db.execute(
"INSERT INTO traces(id, rx_time, rx_iso, src, dest) VALUES (?,?,?,?,?)",
[10, now, rx_iso, 0x00bb0001, 0xdeadbeef],
)
db.execute(
"INSERT INTO traces(id, rx_time, rx_iso, src, dest) VALUES (?,?,?,?,?)",
[11, now, rx_iso, 0x00bb0002, 0xdeadbeef],
)
db.execute(
"INSERT INTO trace_hops(trace_id, hop_index, node_id) VALUES (?,?,?)",
[10, 0, 0x00bb0002],
)
db.execute(
"INSERT INTO ingestors(node_id, start_time, last_seen_time, version) VALUES (?,?,?,?)",
["!visible0", now, now, "1.0"],
)
db.execute(
"INSERT INTO ingestors(node_id, start_time, last_seen_time, version) VALUES (?,?,?,?)",
["!optout01", now, now, "1.0"],
)
end
end
it "excludes opted-out nodes from query_nodes by long_name marker" do
ids = queries.query_nodes(50).map { |row| row["node_id"] }
expect(ids).to include("!visible0")
expect(ids).not_to include("!optout01")
end
it "excludes opted-out nodes from query_nodes by short_name marker" do
ids = queries.query_nodes(50).map { |row| row["node_id"] }
expect(ids).not_to include("!optshort")
end
it "returns no row when querying a single opted-out node by id" do
rows = queries.query_nodes(10, node_ref: "!optout01")
expect(rows).to be_empty
end
it "drops chat lines whose sender or recipient is opted out" do
texts = queries.query_messages(50, include_encrypted: true).map { |r| r["text"] }
expect(texts).to include("from-visible")
expect(texts).not_to include("from-optout")
expect(texts).not_to include("to-optout")
end
it "hides position rows for opted-out nodes" do
ids = queries.query_positions(50).map { |r| r["node_id"] }
expect(ids).to include("!visible0")
expect(ids).not_to include("!optout01")
end
it "hides telemetry rows for opted-out nodes" do
ids = queries.query_telemetry(50).map { |r| r["node_id"] }
expect(ids).to include("!visible0")
expect(ids).not_to include("!optout01")
end
it "hides neighbour relationships involving opted-out nodes" do
rows = queries.query_neighbors(50)
expect(rows).to be_empty
end
it "hides traces whose src or dest is opted out" do
ids = queries.query_traces(50).map { |r| r["id"] }
expect(ids).to include(10)
expect(ids).not_to include(11)
end
it "scrubs opted-out hop ids from surviving traces" do
rows = queries.query_traces(50)
trace_10 = rows.find { |r| r["id"] == 10 }
expect(trace_10).not_to be_nil
# Hop 0x00bb0002 belongs to the opted-out node and must be filtered out
# of the hop list even though the parent trace is visible.
expect(trace_10["hops"]).to be_nil
end
it "hides ingestor heartbeats for opted-out nodes" do
ids = queries.query_ingestors(50).map { |r| r["node_id"] }
expect(ids).to include("!visible0")
expect(ids).not_to include("!optout01")
end
it "excludes opted-out nodes from active stats counters" do
stats = queries.query_active_node_stats(now: now)
# Only "!visible0" is visible — opted-out and short-marker nodes are dropped
# from node, message, and telemetry counts alike.
expect(stats["total"]["nodes"]["day"]).to eq(1)
expect(stats["total"]["nodes"]["month"]).to eq(1)
# Message id 11 (from opt-out) and id 12 (to opt-out) drop; only the
# visible → broadcast line survives.
expect(stats["total"]["messages"]["day"]).to eq(1)
# Telemetry umbrella = positions(1) + telemetry(1) + neighbors(0, peer
# opted out) + traces(1, the opted-out src dropped).
expect(stats["total"]["telemetry"]["day"]).to eq(3)
end
end
describe "#query_active_node_stats" do
it "returns the full scope × metric × window tree" do
stats = queries.query_active_node_stats(now: now)
%w[total meshcore meshtastic reticulum].each do |scope|
expect(stats).to have_key(scope)
%w[nodes messages telemetry].each do |metric|
expect(stats[scope][metric].keys).to contain_exactly("hour", "day", "week", "month")
stats[scope][metric].each_value { |count| expect(count).to be_a(Integer) }
end
end
# The pre-0.7.0 flat keys are gone (intended breaking change).
expect(stats).not_to have_key("active_nodes")
expect(stats).not_to have_key("hour")
end
it "counts total across all protocols with per-protocol subsets" do
with_db do |db|
db.execute(
"INSERT INTO nodes(node_id, num, last_heard, first_heard, role, protocol) VALUES (?,?,?,?,?,?)",
["!core0001", 0xC0000001, now, now, "CLIENT", "meshcore"],
)
db.execute(
"INSERT INTO nodes(node_id, num, last_heard, first_heard, role, protocol) VALUES (?,?,?,?,?,?)",
["!tastic01", 0x70000001, now, now, "CLIENT", "meshtastic"],
)
end
stats = queries.query_active_node_stats(now: now)
expect(stats["meshcore"]["nodes"]["day"]).to eq(1)
expect(stats["meshtastic"]["nodes"]["day"]).to eq(1)
expect(stats["total"]["nodes"]["day"]).to eq(2)
expect(stats["total"]["nodes"]["day"]).to eq(
stats["meshcore"]["nodes"]["day"] + stats["meshtastic"]["nodes"]["day"],
)
end
it "aggregates positions, telemetry, neighbors, and traces into the telemetry umbrella" do
with_db do |db|
rx_iso = Time.at(now).utc.iso8601
db.execute("INSERT INTO positions(id, rx_time, rx_iso, node_id) VALUES (?,?,?,?)", [1, now, rx_iso, "!feed0001"])
db.execute("INSERT INTO telemetry(id, rx_time, rx_iso, node_id) VALUES (?,?,?,?)", [1, now, rx_iso, "!feed0001"])
db.execute("INSERT INTO neighbors(node_id, neighbor_id, rx_time) VALUES (?,?,?)", ["!feed0001", "!feed0002", now])
db.execute("INSERT INTO traces(id, rx_time, rx_iso, src, dest) VALUES (?,?,?,?,?)", [1, now, rx_iso, 0x11, 0x22])
end
stats = queries.query_active_node_stats(now: now)
# One row in each of the four umbrella tables → 4.
expect(stats["total"]["telemetry"]["hour"]).to eq(4)
end
it "caps the node month bucket at four_weeks_seconds (28 days)" do
twenty_nine_days_ago = now - (29 * 24 * 60 * 60)
with_db do |db|
db.execute(
"INSERT INTO nodes(node_id, num, short_name, last_heard, first_heard, role) VALUES (?,?,?,?,?,?)",
["!29dayago", 0x29000001, "OLD", twenty_nine_days_ago, twenty_nine_days_ago, "CLIENT"],
)
end
stats = queries.query_active_node_stats(now: now)
# 28-day cap means this 29-day-old row falls outside the "month" bucket.
expect(stats["total"]["nodes"]["month"]).to eq(0)
end
it "caps the telemetry umbrella month bucket at the visibility floor" do
old = now - (29 * 24 * 60 * 60)
with_db do |db|
db.execute("INSERT INTO positions(id, rx_time, rx_iso, node_id) VALUES (?,?,?,?)", [1, old, Time.at(old).utc.iso8601, "!feed0001"])
db.execute("INSERT INTO positions(id, rx_time, rx_iso, node_id) VALUES (?,?,?,?)", [2, now, Time.at(now).utc.iso8601, "!feed0001"])
end
stats = queries.query_active_node_stats(now: now)
# Only the recent row falls inside the month window (28-day floor; the spec
# stubs four_weeks_seconds to 7 days, so the 29-day-old row is excluded).
expect(stats["total"]["telemetry"]["month"]).to eq(1)
end
it "emits reticulum as an all-zero stub" do
with_db do |db|
db.execute("INSERT INTO nodes(node_id, num, last_heard, first_heard, role) VALUES (?,?,?,?,?)", ["!any00001", 1, now, now, "CLIENT"])
end
stats = queries.query_active_node_stats(now: now)
stats["reticulum"].each_value do |windows|
windows.each_value { |count| expect(count).to eq(0) }
end
end
it "zeroes message counts in private mode but keeps nodes and telemetry" do
private_queries = Class.new(harness_class) do
def private_mode?
true
end
end.new
with_db do |db|
rx_iso = Time.at(now).utc.iso8601
db.execute("INSERT INTO nodes(node_id, num, last_heard, first_heard, role) VALUES (?,?,?,?,?)", ["!priv0001", 1, now, now, "CLIENT"])
db.execute("INSERT INTO messages(id, rx_time, rx_iso, from_id, to_id, channel, text) VALUES (?,?,?,?,?,?,?)", [1, now, rx_iso, "!priv0001", "!ffffffff", 0, "hi"])
db.execute("INSERT INTO positions(id, rx_time, rx_iso, node_id) VALUES (?,?,?,?)", [1, now, rx_iso, "!priv0001"])
end
stats = private_queries.query_active_node_stats(now: now)
expect(stats["total"]["messages"]["day"]).to eq(0)
expect(stats["meshtastic"]["messages"]["day"]).to eq(0)
# Non-message metrics are unaffected by privacy mode.
expect(stats["total"]["nodes"]["day"]).to eq(1)
expect(stats["total"]["telemetry"]["day"]).to eq(1)
end
end
describe "#query_telemetry_buckets" do
it "clamps oversized window_seconds to the 28-day visibility cap" do
huge_window = PotatoMesh::Config.four_weeks_seconds * 50
rows = queries.query_telemetry_buckets(
window_seconds: huge_window,
bucket_seconds: PotatoMesh::App::Queries::DEFAULT_TELEMETRY_BUCKET_SECONDS,
)
# Even with a 50× window the implementation must not look beyond 28 days;
# the returned bucket set therefore matches a 28-day query exactly.
reference = queries.query_telemetry_buckets(
window_seconds: PotatoMesh::Config.four_weeks_seconds,
bucket_seconds: PotatoMesh::App::Queries::DEFAULT_TELEMETRY_BUCKET_SECONDS,
)
expect(rows.length).to eq(reference.length)
end
end
end