# 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
