mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-07-05 09:21:42 +02:00
5e0363a0ec
* web: breaking change on stats api * address review comments
1283 lines
48 KiB
Ruby
1283 lines
48 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 "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
|