mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-05-01 11:02:35 +02:00
* web: reference meshcore nodes in chat * data: add adv_name to messages * web: address review comments * derive actual companion from name string * derive actual companion from name string * derive actual companion from name string * web: address review comments * web: address review comments
573 lines
20 KiB
Ruby
573 lines
20 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::DataProcessing do
|
|
# Build a minimal host class so we can call the module methods in isolation.
|
|
let(:harness_class) do
|
|
Class.new do
|
|
include PotatoMesh::App::DataProcessing
|
|
include PotatoMesh::App::Helpers
|
|
|
|
def debug_log(message, **); end
|
|
|
|
def warn_log(message, **); end
|
|
|
|
def with_busy_retry
|
|
yield
|
|
end
|
|
|
|
def update_prometheus_metrics(*); end
|
|
|
|
def prom_report_ids
|
|
[]
|
|
end
|
|
|
|
def private_mode?
|
|
false
|
|
end
|
|
|
|
def normalize_node_id(_db, node_ref)
|
|
parts = canonical_node_parts(node_ref)
|
|
parts ? parts[0] : nil
|
|
end
|
|
|
|
def resolve_protocol(_db, _ingestor, cache: nil)
|
|
"meshtastic"
|
|
end
|
|
end
|
|
end
|
|
|
|
subject(:dp) { harness_class.new }
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# coerce_bool
|
|
# ---------------------------------------------------------------------------
|
|
describe "#coerce_bool" do
|
|
it "converts true to 1" do
|
|
expect(dp.coerce_bool(true)).to eq(1)
|
|
end
|
|
|
|
it "converts false to 0" do
|
|
expect(dp.coerce_bool(false)).to eq(0)
|
|
end
|
|
|
|
it "passes through other values unchanged" do
|
|
expect(dp.coerce_bool(nil)).to be_nil
|
|
expect(dp.coerce_bool("yes")).to eq("yes")
|
|
expect(dp.coerce_bool(42)).to eq(42)
|
|
end
|
|
end
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# resolve_node_num
|
|
# ---------------------------------------------------------------------------
|
|
describe "#resolve_node_num" do
|
|
it "returns an integer payload num directly" do
|
|
expect(dp.resolve_node_num("!aabbccdd", { "num" => 0xaabbccdd })).to eq(0xaabbccdd)
|
|
end
|
|
|
|
it "handles a numeric (non-integer) payload num" do
|
|
expect(dp.resolve_node_num("!aabbccdd", { "num" => 305_441_741.0 })).to eq(305_441_741)
|
|
end
|
|
|
|
it "parses a decimal string num" do
|
|
expect(dp.resolve_node_num("!aabbccdd", { "num" => "12345" })).to eq(12345)
|
|
end
|
|
|
|
it "parses a hex-prefixed string num" do
|
|
expect(dp.resolve_node_num("!aabbccdd", { "num" => "0xAABBCCDD" })).to eq(0xaabbccdd)
|
|
end
|
|
|
|
it "falls back to node_id hex when num is absent" do
|
|
expect(dp.resolve_node_num("!aabbccdd", {})).to eq(0xaabbccdd)
|
|
end
|
|
|
|
it "returns nil for invalid inputs" do
|
|
expect(dp.resolve_node_num(nil, {})).to be_nil
|
|
end
|
|
end
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# canonical_node_parts
|
|
# ---------------------------------------------------------------------------
|
|
describe "#canonical_node_parts" do
|
|
it "parses !hex notation" do
|
|
parts = dp.canonical_node_parts("!aabbccdd")
|
|
expect(parts).not_to be_nil
|
|
expect(parts[0]).to eq("!aabbccdd")
|
|
expect(parts[1]).to eq(0xaabbccdd)
|
|
end
|
|
|
|
it "parses a numeric node_ref" do
|
|
parts = dp.canonical_node_parts(0xaabbccdd)
|
|
expect(parts).not_to be_nil
|
|
expect(parts[0]).to start_with("!")
|
|
expect(parts[1]).to eq(0xaabbccdd)
|
|
end
|
|
|
|
it "returns nil for an invalid string" do
|
|
expect(dp.canonical_node_parts("not_a_node")).to be_nil
|
|
end
|
|
|
|
it "returns nil for nil without a fallback" do
|
|
expect(dp.canonical_node_parts(nil)).to be_nil
|
|
end
|
|
|
|
it "uses fallback_num when node_ref is nil" do
|
|
parts = dp.canonical_node_parts(nil, 0xaabbccdd)
|
|
expect(parts).not_to be_nil
|
|
expect(parts[1]).to eq(0xaabbccdd)
|
|
end
|
|
end
|
|
|
|
shared_context "with isolated db" do
|
|
around do |example|
|
|
Dir.mktmpdir("dp-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(:trace_neighbor_window_seconds).and_return(604_800)
|
|
allow(PotatoMesh::Config).to receive(:debug?).and_return(false)
|
|
db_helper = Object.new.extend(PotatoMesh::App::Database)
|
|
db_helper.init_db
|
|
db_helper.ensure_schema_upgrades
|
|
example.run
|
|
end
|
|
end
|
|
end
|
|
|
|
def open_db
|
|
db = SQLite3::Database.new(PotatoMesh::Config.db_path)
|
|
db.results_as_hash = true
|
|
db
|
|
end
|
|
|
|
# Return the full node row for the canonical test node ID.
|
|
def read_node(db)
|
|
db.execute("SELECT * FROM nodes WHERE node_id = '!aabbccdd'").first
|
|
end
|
|
|
|
# Insert the canonical test node with full user info and CLIENT_BASE role.
|
|
def seed_node(db)
|
|
dp.upsert_node(db, "!aabbccdd", {
|
|
"lastHeard" => now - 100,
|
|
"num" => 0xaabbccdd,
|
|
"user" => {
|
|
"role" => "CLIENT_BASE",
|
|
"longName" => "Real Long Name",
|
|
"shortName" => "RLN",
|
|
"macaddr" => "aa:bb:cc:dd:ee:ff",
|
|
"hwModel" => "TBEAM",
|
|
"publicKey" => "abc123",
|
|
},
|
|
})
|
|
end
|
|
|
|
let(:now) { Time.now.to_i }
|
|
end
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# insert_telemetry (telemetry_type validation path)
|
|
# ---------------------------------------------------------------------------
|
|
describe "#insert_telemetry" do
|
|
include_context "with isolated db"
|
|
|
|
let(:valid_payload) do
|
|
{
|
|
"id" => 42,
|
|
"node_id" => "!aabbccdd",
|
|
"rx_time" => now,
|
|
"telemetry_type" => "device",
|
|
"deviceMetrics" => { "batteryLevel" => 80 },
|
|
}
|
|
end
|
|
|
|
it "inserts a telemetry row with a valid telemetry_type" do
|
|
db = open_db
|
|
dp.insert_telemetry(db, valid_payload)
|
|
row = db.execute("SELECT telemetry_type FROM telemetry WHERE id = 42").first
|
|
db.close
|
|
expect(row["telemetry_type"]).to eq("device")
|
|
end
|
|
|
|
it "treats invalid telemetry_type values as nil (auto-inferred)" do
|
|
db = open_db
|
|
payload = valid_payload.merge("telemetry_type" => "bogus_type")
|
|
dp.insert_telemetry(db, payload)
|
|
row = db.execute("SELECT telemetry_type FROM telemetry WHERE id = 42").first
|
|
db.close
|
|
# "bogus_type" is not in VALID_TELEMETRY_TYPES; the value should be
|
|
# replaced by the auto-inferred type ("device") since deviceMetrics is present.
|
|
expect(%w[device nil]).to include(row["telemetry_type"].to_s)
|
|
end
|
|
|
|
it "returns nil for a non-hash payload" do
|
|
db = open_db
|
|
result = dp.insert_telemetry(db, "not a hash")
|
|
db.close
|
|
expect(result).to be_nil
|
|
end
|
|
|
|
it "returns nil when no telemetry id is present" do
|
|
db = open_db
|
|
result = dp.insert_telemetry(db, { "node_id" => "!aabbccdd" })
|
|
db.close
|
|
expect(result).to be_nil
|
|
end
|
|
end
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# upsert_node — Bug 1: lastHeard = 0 must not be stored as 0
|
|
# ---------------------------------------------------------------------------
|
|
describe "#upsert_node — last_heard zero handling" do
|
|
include_context "with isolated db"
|
|
|
|
it "treats lastHeard=0 as absent, storing approximately now instead" do
|
|
db = open_db
|
|
dp.upsert_node(db, "!aabbccdd", { "lastHeard" => 0, "num" => 0xaabbccdd })
|
|
lh = read_node(db)["last_heard"]
|
|
db.close
|
|
expect(lh).not_to eq(0)
|
|
expect(lh).to be_within(5).of(now)
|
|
end
|
|
|
|
it "uses position time as last_heard fallback when lastHeard is 0" do
|
|
pt = now - 300
|
|
db = open_db
|
|
dp.upsert_node(db, "!aabbccdd", {
|
|
"lastHeard" => 0,
|
|
"num" => 0xaabbccdd,
|
|
"position" => { "time" => pt, "latitude" => 1.0, "longitude" => 2.0 },
|
|
})
|
|
expect(read_node(db)["last_heard"]).to eq(pt)
|
|
db.close
|
|
end
|
|
|
|
it "stores a positive lastHeard value as-is" do
|
|
lh = now - 120
|
|
db = open_db
|
|
dp.upsert_node(db, "!aabbccdd", { "lastHeard" => lh, "num" => 0xaabbccdd })
|
|
expect(read_node(db)["last_heard"]).to eq(lh)
|
|
db.close
|
|
end
|
|
|
|
it "includes the node in query_nodes results after lastHeard=0 fix" do
|
|
db = open_db
|
|
dp.upsert_node(db, "!aabbccdd", { "lastHeard" => 0, "num" => 0xaabbccdd })
|
|
db.close
|
|
# query_nodes applies a 7-day floor; the node must appear
|
|
expect(dp.query_nodes(100).map { |n| n["node_id"] }).to include("!aabbccdd")
|
|
end
|
|
end
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# upsert_node — Bug 2: role must not be reset by no-user packets
|
|
# ---------------------------------------------------------------------------
|
|
describe "#upsert_node — role preservation" do
|
|
include_context "with isolated db"
|
|
|
|
it "preserves CLIENT_BASE role when a no-user packet arrives" do
|
|
db = open_db
|
|
seed_node(db)
|
|
dp.upsert_node(db, "!aabbccdd", { "lastHeard" => now, "num" => 0xaabbccdd })
|
|
expect(read_node(db)["role"]).to eq("CLIENT_BASE")
|
|
db.close
|
|
end
|
|
|
|
it "updates role when an explicit role is supplied" do
|
|
db = open_db
|
|
seed_node(db)
|
|
dp.upsert_node(db, "!aabbccdd", {
|
|
"lastHeard" => now,
|
|
"num" => 0xaabbccdd,
|
|
"user" => { "role" => "CLIENT", "longName" => "Real Long Name", "shortName" => "RLN" },
|
|
})
|
|
expect(read_node(db)["role"]).to eq("CLIENT")
|
|
db.close
|
|
end
|
|
|
|
it "stores NULL role for new nodes without user info (display layer supplies CLIENT)" do
|
|
db = open_db
|
|
dp.upsert_node(db, "!aabbccdd", { "lastHeard" => now, "num" => 0xaabbccdd })
|
|
# NULL is stored; query_nodes applies r["role"] ||= "CLIENT" for display
|
|
expect(read_node(db)["role"]).to be_nil
|
|
db.close
|
|
end
|
|
end
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# upsert_node — Bug 3: identity fields must not be overwritten with NULL
|
|
# ---------------------------------------------------------------------------
|
|
describe "#upsert_node — identity field preservation" do
|
|
include_context "with isolated db"
|
|
|
|
it "preserves all identity fields when a no-user packet arrives" do
|
|
db = open_db
|
|
seed_node(db)
|
|
dp.upsert_node(db, "!aabbccdd", { "lastHeard" => now, "num" => 0xaabbccdd })
|
|
row = read_node(db)
|
|
db.close
|
|
expect(row["short_name"]).to eq("RLN")
|
|
expect(row["long_name"]).to eq("Real Long Name")
|
|
expect(row["macaddr"]).to eq("aa:bb:cc:dd:ee:ff")
|
|
expect(row["hw_model"]).to eq("TBEAM")
|
|
expect(row["public_key"]).to eq("abc123")
|
|
end
|
|
|
|
it "updates short_name when a real value is supplied" do
|
|
db = open_db
|
|
seed_node(db)
|
|
dp.upsert_node(db, "!aabbccdd", {
|
|
"lastHeard" => now,
|
|
"num" => 0xaabbccdd,
|
|
"user" => { "shortName" => "NEW", "longName" => "New Long Name" },
|
|
})
|
|
expect(read_node(db)["short_name"]).to eq("NEW")
|
|
db.close
|
|
end
|
|
|
|
it "does not overwrite a real long_name with a generic placeholder" do
|
|
db = open_db
|
|
seed_node(db)
|
|
dp.upsert_node(db, "!aabbccdd", {
|
|
"lastHeard" => now,
|
|
"num" => 0xaabbccdd,
|
|
"user" => { "longName" => "Meshtastic CCDD", "shortName" => "RLN" },
|
|
})
|
|
expect(read_node(db)["long_name"]).to eq("Real Long Name")
|
|
db.close
|
|
end
|
|
|
|
it "overwrites a generic placeholder long_name with a real long_name" do
|
|
db = open_db
|
|
dp.upsert_node(db, "!aabbccdd", {
|
|
"lastHeard" => now - 100,
|
|
"num" => 0xaabbccdd,
|
|
"user" => { "longName" => "Meshtastic CCDD", "shortName" => "CCDD" },
|
|
})
|
|
dp.upsert_node(db, "!aabbccdd", {
|
|
"lastHeard" => now,
|
|
"num" => 0xaabbccdd,
|
|
"user" => { "longName" => "Real Long Name", "shortName" => "RLN" },
|
|
})
|
|
expect(read_node(db)["long_name"]).to eq("Real Long Name")
|
|
db.close
|
|
end
|
|
end
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# upsert_node — synthetic flag + merge
|
|
# ---------------------------------------------------------------------------
|
|
describe "#upsert_node — synthetic node handling", :db do
|
|
include_context "with isolated db"
|
|
|
|
let(:now) { Time.now.to_i }
|
|
|
|
def seed_message(db, from_id:)
|
|
db.execute(
|
|
"INSERT INTO messages(id,rx_time,rx_iso,from_id,to_id,protocol) VALUES (?,?,?,?,?,?)",
|
|
[42, now, "2025-01-01T00:00:00Z", from_id, "^all", "meshcore"],
|
|
)
|
|
end
|
|
|
|
it "stores synthetic=1 when user.synthetic is true" do
|
|
db = open_db
|
|
dp.upsert_node(db, "!synth111", {
|
|
"lastHeard" => now,
|
|
"protocol" => "meshcore",
|
|
"user" => { "longName" => "Alice", "shortName" => " A ", "role" => "COMPANION", "synthetic" => true },
|
|
}, protocol: "meshcore")
|
|
row = db.execute("SELECT synthetic FROM nodes WHERE node_id = '!synth111'").first
|
|
expect(row[0]).to eq(1)
|
|
db.close
|
|
end
|
|
|
|
it "stores synthetic=0 when user.synthetic is false" do
|
|
db = open_db
|
|
dp.upsert_node(db, "!real1111", {
|
|
"lastHeard" => now,
|
|
"protocol" => "meshcore",
|
|
"user" => { "longName" => "Alice", "shortName" => " A ", "role" => "COMPANION", "synthetic" => false },
|
|
}, protocol: "meshcore")
|
|
row = db.execute("SELECT synthetic FROM nodes WHERE node_id = '!real1111'").first
|
|
expect(row[0]).to eq(0)
|
|
db.close
|
|
end
|
|
|
|
it "does not overwrite a real node with a synthetic upsert" do
|
|
db = open_db
|
|
# Insert real node first.
|
|
dp.upsert_node(db, "!aabbccdd", {
|
|
"lastHeard" => now - 100,
|
|
"user" => { "longName" => "Alice", "shortName" => " A ", "role" => "COMPANION" },
|
|
}, protocol: "meshcore")
|
|
# Attempt to overwrite with synthetic upsert at a later time.
|
|
dp.upsert_node(db, "!aabbccdd", {
|
|
"lastHeard" => now,
|
|
"user" => { "longName" => "Alice", "shortName" => " A ", "role" => "COMPANION", "synthetic" => true },
|
|
}, protocol: "meshcore")
|
|
row = db.execute("SELECT synthetic FROM nodes WHERE node_id = '!aabbccdd'").first
|
|
expect(row[0]).to eq(0)
|
|
db.close
|
|
end
|
|
|
|
it "real wins over synthetic — synthetic=0 is never overwritten by synthetic=1" do
|
|
db = open_db
|
|
# Insert synthetic first, then real.
|
|
dp.upsert_node(db, "!synth222", {
|
|
"lastHeard" => now - 200,
|
|
"user" => { "longName" => "Bob", "shortName" => " B ", "role" => "COMPANION", "synthetic" => true },
|
|
}, protocol: "meshcore")
|
|
dp.upsert_node(db, "!synth222", {
|
|
"lastHeard" => now,
|
|
"user" => { "longName" => "Bob", "shortName" => " B ", "role" => "COMPANION", "synthetic" => false },
|
|
}, protocol: "meshcore")
|
|
row = db.execute("SELECT synthetic FROM nodes WHERE node_id = '!synth222'").first
|
|
expect(row[0]).to eq(0)
|
|
db.close
|
|
end
|
|
|
|
it "migrates messages from synthetic node to real node on name match" do
|
|
db = open_db
|
|
synth_id = "!synth333"
|
|
real_id = "!real3333"
|
|
# Create synthetic node and a message from it.
|
|
dp.upsert_node(db, synth_id, {
|
|
"lastHeard" => now - 500,
|
|
"user" => { "longName" => "Carol", "shortName" => " C ", "role" => "COMPANION", "synthetic" => true },
|
|
}, protocol: "meshcore")
|
|
seed_message(db, from_id: synth_id)
|
|
# Upsert the real node with the same long name.
|
|
dp.upsert_node(db, real_id, {
|
|
"lastHeard" => now,
|
|
"user" => { "longName" => "Carol", "shortName" => " C ", "role" => "COMPANION", "publicKey" => "cc" * 32 },
|
|
}, protocol: "meshcore")
|
|
# Message should now point to the real node.
|
|
msg_from = db.execute("SELECT from_id FROM messages WHERE id = 42").first[0]
|
|
expect(msg_from).to eq(real_id)
|
|
# Synthetic node should be gone.
|
|
synth_row = db.execute("SELECT node_id FROM nodes WHERE node_id = ?", [synth_id]).first
|
|
expect(synth_row).to be_nil
|
|
db.close
|
|
end
|
|
|
|
it "does not delete real nodes during merge" do
|
|
db = open_db
|
|
real_id = "!real4444"
|
|
# Insert a real node with the same long name as the incoming real node.
|
|
dp.upsert_node(db, real_id, {
|
|
"lastHeard" => now - 100,
|
|
"user" => { "longName" => "Dave", "shortName" => " D ", "role" => "COMPANION" },
|
|
}, protocol: "meshcore")
|
|
# Upsert same node again — should not delete itself.
|
|
dp.upsert_node(db, real_id, {
|
|
"lastHeard" => now,
|
|
"user" => { "longName" => "Dave", "shortName" => " D ", "role" => "COMPANION" },
|
|
}, protocol: "meshcore")
|
|
row = db.execute("SELECT node_id FROM nodes WHERE node_id = ?", [real_id]).first
|
|
expect(row).not_to be_nil
|
|
db.close
|
|
end
|
|
|
|
it "migrates messages from multiple synthetic nodes to a single real node" do
|
|
db = open_db
|
|
synth_a = "!synth5a5a"
|
|
synth_b = "!synth5b5b"
|
|
real_id = "!real5555"
|
|
# Two synthetic nodes with the same long name (could happen from two
|
|
# ingestors or a race).
|
|
dp.upsert_node(db, synth_a, {
|
|
"lastHeard" => now - 600,
|
|
"user" => { "longName" => "Eve", "shortName" => " E ", "role" => "COMPANION", "synthetic" => true },
|
|
}, protocol: "meshcore")
|
|
dp.upsert_node(db, synth_b, {
|
|
"lastHeard" => now - 500,
|
|
"user" => { "longName" => "Eve", "shortName" => " E ", "role" => "COMPANION", "synthetic" => true },
|
|
}, protocol: "meshcore")
|
|
db.execute(
|
|
"INSERT INTO messages(id,rx_time,rx_iso,from_id,to_id,protocol) VALUES (?,?,?,?,?,?)",
|
|
[51, now - 600, "2025-01-01T00:00:00Z", synth_a, "^all", "meshcore"],
|
|
)
|
|
db.execute(
|
|
"INSERT INTO messages(id,rx_time,rx_iso,from_id,to_id,protocol) VALUES (?,?,?,?,?,?)",
|
|
[52, now - 500, "2025-01-01T00:00:00Z", synth_b, "^all", "meshcore"],
|
|
)
|
|
# Upsert real node.
|
|
dp.upsert_node(db, real_id, {
|
|
"lastHeard" => now,
|
|
"user" => { "longName" => "Eve", "shortName" => " E ", "role" => "COMPANION", "publicKey" => "ee" * 32 },
|
|
}, protocol: "meshcore")
|
|
# Both messages should now reference the real node.
|
|
from_ids = db.execute("SELECT from_id FROM messages WHERE id IN (51,52) ORDER BY id").map { |r| r[0] }
|
|
expect(from_ids).to all(eq(real_id))
|
|
# Both synthetic nodes gone.
|
|
remaining = db.execute("SELECT node_id FROM nodes WHERE node_id IN (?,?)", [synth_a, synth_b]).flatten
|
|
expect(remaining).to be_empty
|
|
db.close
|
|
end
|
|
end
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# merge_synthetic_nodes
|
|
# ---------------------------------------------------------------------------
|
|
describe "#merge_synthetic_nodes" do
|
|
include_context "with isolated db"
|
|
|
|
let(:now) { Time.now.to_i }
|
|
|
|
it "is a no-op when no synthetic nodes match the long name" do
|
|
db = open_db
|
|
dp.upsert_node(db, "!real6666", {
|
|
"lastHeard" => now - 100,
|
|
"user" => { "longName" => "Frank", "shortName" => " F " },
|
|
}, protocol: "meshcore")
|
|
# Should not raise and should leave the real node intact.
|
|
dp.merge_synthetic_nodes(db, "!real6666", "Frank")
|
|
row = db.execute("SELECT node_id FROM nodes WHERE node_id = '!real6666'").first
|
|
expect(row).not_to be_nil
|
|
db.close
|
|
end
|
|
|
|
it "does not migrate messages from a synthetic node on a different protocol" do
|
|
db = open_db
|
|
# A synthetic meshtastic node that happens to share the same long name as
|
|
# an incoming real meshcore contact must NOT be merged.
|
|
synth_id = "!synth7777"
|
|
real_id = "!real7777"
|
|
db.execute(
|
|
"INSERT INTO nodes(node_id,long_name,protocol,synthetic,last_heard,first_heard) VALUES (?,?,?,?,?,?)",
|
|
[synth_id, "Grace", "meshtastic", 1, now - 100, now - 100],
|
|
)
|
|
db.execute(
|
|
"INSERT INTO messages(id,rx_time,rx_iso,from_id,to_id,protocol) VALUES (?,?,?,?,?,?)",
|
|
[61, now - 100, "2025-01-01T00:00:00Z", synth_id, "^all", "meshtastic"],
|
|
)
|
|
dp.merge_synthetic_nodes(db, real_id, "Grace")
|
|
# meshtastic synthetic node must be untouched.
|
|
synth_row = db.execute("SELECT node_id FROM nodes WHERE node_id = ?", [synth_id]).first
|
|
expect(synth_row).not_to be_nil
|
|
msg_from = db.execute("SELECT from_id FROM messages WHERE id = 61").first[0]
|
|
expect(msg_from).to eq(synth_id)
|
|
db.close
|
|
end
|
|
end
|
|
end
|