mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-08-06 08:53:41 +02:00
150 lines
4.7 KiB
Ruby
150 lines
4.7 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 "json"
|
|
require "time"
|
|
|
|
# Web-side coverage for the per-heartbeat activity time-series (SPEC MA3) and
|
|
# the additive +packets+ delta on the ingestor heartbeat (MA2). A heartbeat
|
|
# carrying a non-negative integer +packets+ appends exactly one append-only
|
|
# +ingestor_activity+ row; anything else records nothing.
|
|
RSpec.describe "Ingestor activity time-series (MA3)" do
|
|
let(:app) { Sinatra::Application }
|
|
let(:api_token) { "secret-token" }
|
|
let(:auth_headers) do
|
|
{
|
|
"CONTENT_TYPE" => "application/json",
|
|
"HTTP_AUTHORIZATION" => "Bearer #{api_token}",
|
|
}
|
|
end
|
|
|
|
before do
|
|
@original_token = ENV["API_TOKEN"]
|
|
ENV["API_TOKEN"] = api_token
|
|
clear_tables
|
|
end
|
|
|
|
after do
|
|
ENV["API_TOKEN"] = @original_token
|
|
clear_tables
|
|
end
|
|
|
|
def clear_tables
|
|
with_db do |db|
|
|
db.execute("DELETE FROM ingestor_activity")
|
|
db.execute("DELETE FROM ingestors")
|
|
end
|
|
end
|
|
|
|
def with_db(readonly: false)
|
|
db = PotatoMesh::Application.open_database(readonly: readonly)
|
|
db.busy_timeout = PotatoMesh::Config.db_busy_timeout_ms
|
|
db.execute("PRAGMA foreign_keys = ON")
|
|
yield db
|
|
ensure
|
|
db&.close
|
|
end
|
|
|
|
def post_heartbeat(overrides = {})
|
|
now = Time.now.to_i
|
|
payload = {
|
|
node_id: "!abc12345",
|
|
start_time: now - 3600,
|
|
last_seen_time: now - 60,
|
|
version: "0.6.0",
|
|
protocol: "meshtastic",
|
|
}.merge(overrides)
|
|
post "/api/ingestors", payload.to_json, auth_headers
|
|
end
|
|
|
|
def activity_rows
|
|
with_db(readonly: true) do |db|
|
|
db.execute(
|
|
"SELECT ingestor_id, at, packets, protocol FROM ingestor_activity ORDER BY id",
|
|
)
|
|
end
|
|
end
|
|
|
|
describe "POST /api/ingestors" do
|
|
it "records ingestor activity for a heartbeat carrying packets" do
|
|
now = Time.now.to_i
|
|
post_heartbeat(last_seen_time: now - 60, packets: 42, protocol: "meshcore")
|
|
expect(last_response.status).to eq(201)
|
|
|
|
rows = activity_rows
|
|
expect(rows.length).to eq(1)
|
|
ingestor_id, at, packets, protocol = rows.first
|
|
expect(ingestor_id).to eq("!abc12345")
|
|
expect(at).to eq(now - 60)
|
|
expect(packets).to eq(42)
|
|
expect(protocol).to eq("meshcore")
|
|
end
|
|
|
|
it "records ingestor activity of zero for an idle heartbeat" do
|
|
post_heartbeat(packets: 0)
|
|
expect(last_response.status).to eq(201)
|
|
rows = activity_rows
|
|
expect(rows.length).to eq(1)
|
|
expect(rows.first[2]).to eq(0)
|
|
end
|
|
|
|
it "records ingestor activity independently per ingestor" do
|
|
post_heartbeat(node_id: "!abc12345", packets: 10)
|
|
post_heartbeat(node_id: "!def67890", packets: 25)
|
|
expect(activity_rows.map { |r| [r[0], r[2]] }).to contain_exactly(
|
|
["!abc12345", 10],
|
|
["!def67890", 25],
|
|
)
|
|
end
|
|
|
|
it "appends a fresh activity row on every heartbeat (append-only)" do
|
|
post_heartbeat(node_id: "!abc12345", packets: 3)
|
|
post_heartbeat(node_id: "!abc12345", packets: 4)
|
|
expect(activity_rows.map { |r| r[2] }).to eq([3, 4])
|
|
end
|
|
|
|
it "records no activity when packets is absent (older ingestor)" do
|
|
post_heartbeat
|
|
expect(last_response.status).to eq(201)
|
|
expect(activity_rows).to be_empty
|
|
end
|
|
|
|
it "records no activity when packets is negative or non-numeric" do
|
|
post_heartbeat(node_id: "!abc12345", packets: -5)
|
|
post_heartbeat(node_id: "!def67890", packets: "abc")
|
|
expect(activity_rows).to be_empty
|
|
end
|
|
|
|
it "still records the heartbeat when the activity insert fails" do
|
|
# Force the supplementary activity INSERT to raise by removing its table,
|
|
# then prove the liveness heartbeat still returns 201 (graceful
|
|
# degradation). The table is restored so the surrounding suite is
|
|
# unaffected.
|
|
with_db { |db| db.execute("ALTER TABLE ingestor_activity RENAME TO ingestor_activity_bak") }
|
|
begin
|
|
post_heartbeat(packets: 99)
|
|
expect(last_response.status).to eq(201)
|
|
ensure
|
|
with_db do |db|
|
|
db.execute("ALTER TABLE ingestor_activity_bak RENAME TO ingestor_activity")
|
|
end
|
|
end
|
|
expect(activity_rows).to be_empty
|
|
end
|
|
end
|
|
end
|