mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-06-26 21:12:07 +02:00
web: fix apis to use consistently use camel case (#802)
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
# Memory index
|
||||
|
||||
- [Web app local run](web-app-local-run.md) — rackup fails (no config.ru); use `ruby app.rb`, binds 41447
|
||||
- [API casing & federation signature](api-casing-and-federation-signature.md) — federation wire is camelCase because it's signed; read API is snake_case; don't rename signed keys
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
---
|
||||
name: api-casing-and-federation-signature
|
||||
description: API JSON casing map + the camelCase federation-signature landmine
|
||||
metadata:
|
||||
type: project
|
||||
---
|
||||
|
||||
**Federation instance signatures are computed over camelCase canonical keys.**
|
||||
`web/lib/potato_mesh/application/federation/signature.rb` `canonical_instance_payload`
|
||||
signs `JSON.generate({contactLink, id, domain, pubkey, name, version, channel,
|
||||
frequency, latitude, longitude, lastUpdateTime, isPrivate}, sort_keys: true)`. So
|
||||
the federation wire — `/.well-known/potato-mesh`, `GET /api/instances`
|
||||
(`instances.rb` `normalize_instance_row`), the announcement payload
|
||||
(`self_instance.rb`) — **must stay camelCase**. Renaming any signed key breaks
|
||||
cross-version signature verification **bilaterally** (no "emit both keys" shim
|
||||
fixes a signature). Treat this as immovable without a signature-version scheme.
|
||||
|
||||
**HTTP API casing map (as of 0.7.0):**
|
||||
- Read collections (`/api/nodes`, `/api/messages`, `/api/positions`,
|
||||
`/api/telemetry`, `/api/traces`, `/api/neighbors`) and `/api/stats`: **snake_case**
|
||||
already (`query_nodes` selects snake columns; matrix reads `rx_time`).
|
||||
- `/version`: **snake_case** since 0.7.0 (was the lone camelCase read response).
|
||||
- `POST /api/nodes` **input**: Meshtastic camelCase, but `upsert_node` now also
|
||||
accepts snake via nil-aware `pick_alias` (camel preferred → ingestor unaffected).
|
||||
- Federation wire: **camelCase** (signed — see above).
|
||||
|
||||
**Gotchas that cost time:**
|
||||
- `/api/nodes` camelCase you see in the frontend is the POST input, the client
|
||||
`normalizeNodeCollection` internal model, and `?? shortName` fallbacks — NOT the
|
||||
GET response. Don't "snake-case /api/nodes"; it already is.
|
||||
- The dashboard frontend reads config from the server-rendered `data-app-config`
|
||||
attribute (`views/layouts/app.erb`, helper `frontend_app_config`), **not** from
|
||||
`/version`. The matrix bridge only pings `/version` for liveness (no key reads).
|
||||
The Flutter app *does* read `/version` config keys (`app/lib/main.dart`) — the
|
||||
one consumer that breaks on a `/version` casing change.
|
||||
|
||||
See [[web-app-local-run]] for running the server to curl these.
|
||||
+102
-5
@@ -99,9 +99,10 @@ private mode — `web/lib/potato_mesh/application/routes/api.rb:49`).
|
||||
|
||||
**A2b. Private flag is advertised (the client uses it to hide chat).**
|
||||
```bash
|
||||
curl -s http://127.0.0.1:41447/version | grep -o '"privateMode":true'
|
||||
curl -s http://127.0.0.1:41447/version | grep -o '"private_mode":true'
|
||||
```
|
||||
**Expected:** prints `"privateMode":true`.
|
||||
**Expected:** prints `"private_mode":true` (snake_case as of 0.7.0 — see
|
||||
[§ Bugfix: API casing consistency](#bugfix-api-casing-consistency)).
|
||||
|
||||
**A2c. Node opt-out marker is honored wherever data is listed/exported.**
|
||||
```bash
|
||||
@@ -339,9 +340,11 @@ Maps to decisions **D10, D11** and the README. *Server env per check.*
|
||||
```bash
|
||||
curl -s http://127.0.0.1:41447/version
|
||||
```
|
||||
**Expected:** a JSON `config` block exposing `siteName`, `channel`, `frequency`,
|
||||
`contactLink`, `mapCenter` (`lat`/`lon`), `maxDistanceKm`, `instanceDomain`, and
|
||||
`privateMode`, reflecting the env vars set at boot (README "Web App" table).
|
||||
**Expected:** a JSON `config` block exposing `site_name`, `channel`, `frequency`,
|
||||
`contact_link`, `map_center` (`lat`/`lon`), `max_distance_km`, `instance_domain`,
|
||||
and `private_mode`, reflecting the env vars set at boot (README "Web App" table).
|
||||
Keys are snake_case as of 0.7.0 (see
|
||||
[§ Bugfix: API casing consistency](#bugfix-api-casing-consistency)).
|
||||
|
||||
### D2 — `ALLOWED_CHANNELS` / `HIDDEN_CHANNELS` enforced (ingestor)
|
||||
```bash
|
||||
@@ -572,3 +575,97 @@ still 404s in private mode **and** message counts are now zeroed, S-A4); and
|
||||
and the dashboard consumer (`stats.js`) are **updated** to read `total.nodes` from
|
||||
the new shape, not removed. No POST/event contract changes, so **C2** and the
|
||||
Python suite are unaffected.
|
||||
|
||||
---
|
||||
|
||||
## Bugfix: API casing consistency
|
||||
|
||||
Two casing inconsistencies on the HTTP API, fixed as a versioned breaking change
|
||||
(0.7.0). The `/version` JSON response moves to snake_case (matching every other
|
||||
read response and `/api/stats`); `POST /api/nodes` **additionally** accepts
|
||||
snake_case node fields so the ingest contract is no longer Meshtastic-camelCase
|
||||
only. The **signed federation wire** (`/.well-known`, `/api/instances`) is
|
||||
deliberately **unchanged** (camelCase — its keys are part of the instance
|
||||
signature, `federation/signature.rb`).
|
||||
|
||||
*Run the server in public mode (`API_TOKEN=acctest PRIVATE=0 FEDERATION=0 bundle exec ruby app.rb`).*
|
||||
|
||||
### BF-A1 — `/version` response is snake_case
|
||||
```bash
|
||||
( cd web && bundle exec rspec spec/app_spec.rb -e "exposes the /version config block in snake_case" )
|
||||
```
|
||||
**Expected:** pass. `GET /version` returns a `config` block keyed in snake_case
|
||||
(`site_name`, `map_center` `{lat,lon}`, `private_mode`, `instance_domain`,
|
||||
`contact_link`, `contact_link_url`, `max_distance_km`, `refresh_interval_seconds`)
|
||||
plus a top-level `last_node_update`. The pre-0.7.0 camelCase keys (`siteName`,
|
||||
`mapCenter`, `privateMode`, …, `lastNodeUpdate`) are **gone**. The federation wire
|
||||
(`/.well-known`, `/api/instances`) stays camelCase (signed).
|
||||
|
||||
### BF-A2 — `POST /api/nodes` accepts snake_case node fields
|
||||
```bash
|
||||
( cd web && bundle exec rspec spec/app_spec.rb -e "accepts snake_case node fields on POST /api/nodes" )
|
||||
```
|
||||
**Expected:** pass. A node POSTed with snake_case fields (`last_heard`,
|
||||
`user.short_name`/`long_name`/`hw_model`, `device_metrics.battery_level`,
|
||||
`position.latitude`/`longitude`) is stored and surfaces on `GET /api/nodes`.
|
||||
camelCase Meshtastic input (`lastHeard`, `user.shortName`, …) continues to work
|
||||
unchanged — acceptance is **additive**, so the existing Python ingestor is
|
||||
unaffected.
|
||||
|
||||
### BF-R1 — Regression: prior acceptance still holds
|
||||
```bash
|
||||
( cd web && npm test ) && ( cd web && bundle exec rspec )
|
||||
( . .venv/bin/activate && pytest -q tests/ )
|
||||
```
|
||||
**Expected:** every prior check still passes. Updated for the `/version` break:
|
||||
**A2b** now asserts `"private_mode":true` (was `"privateMode":true`) and **D1**
|
||||
lists the snake_case config keys. The deployed Flutter app reads the new
|
||||
`/version` keys (`app/lib/main.dart`); older app builds break until updated (the
|
||||
accepted one-way cost of the clean break). `data-app-config` (the server→frontend
|
||||
DOM channel) is intentionally **out of scope** and stays camelCase.
|
||||
|
||||
---
|
||||
|
||||
## Bugfix: API consistency cleanups (I2/I3/I5/I6)
|
||||
|
||||
Four small API consistency fixes shipped in 0.7.0 alongside the casing change
|
||||
above. The signed federation wire (`/.well-known`, `/api/instances` output, the
|
||||
canonical signed payload) stays untouched throughout.
|
||||
|
||||
### IC-A1 — `POST /api/instances` accepts both key casings (I6)
|
||||
```bash
|
||||
( cd web && bundle exec rspec spec/app_spec.rb -e "accepts snake_case optional fields on POST /api/instances" )
|
||||
```
|
||||
**Expected:** pass. Optional fields (`contact_link`, `nodes_count`, …) accept
|
||||
snake_case in addition to camelCase; the camelCase keys and the camelCase signed
|
||||
canonical payload are unchanged.
|
||||
|
||||
### IC-A2 — Only `position_time`, no ISO twin (I2)
|
||||
```bash
|
||||
( cd web && bundle exec rspec spec/app_spec.rb -e "/api/nodes" -e "/api/positions" )
|
||||
```
|
||||
**Expected:** pass. `GET /api/nodes` and `/api/positions` emit `position_time`
|
||||
(unix int) and **no** `pos_time_iso` / `position_time_iso`.
|
||||
|
||||
### IC-A3 — POST ingest routes return 201 (I3)
|
||||
```bash
|
||||
( cd web && bundle exec rspec spec/app_spec.rb -e "POST ingest status codes" )
|
||||
```
|
||||
**Expected:** pass. Every `POST /api/*` ingest route returns `201 Created`
|
||||
(matching `/api/instances`). The ingestor treats any 2xx as success.
|
||||
|
||||
### IC-A4 — List POST routes reject malformed payloads (I5)
|
||||
```bash
|
||||
( cd web && bundle exec rspec spec/app_spec.rb -e "POST payload validation" )
|
||||
```
|
||||
**Expected:** pass. `/api/messages|positions|telemetry|neighbors|traces` return
|
||||
`400 {"error":"invalid payload"}` for a non-array/non-object body, matching the
|
||||
`/api/nodes` Hash check.
|
||||
|
||||
### IC-R1 — Regression
|
||||
```bash
|
||||
( cd web && npm test ) && ( cd web && bundle exec rspec )
|
||||
( . .venv/bin/activate && pytest -q tests/ ) && ( cd matrix && cargo test --all --all-features )
|
||||
```
|
||||
**Expected:** all green. POST `be_ok` assertions were updated to `201` (not
|
||||
removed); the ingestor is unaffected (2xx success); the matrix bridge is GET-only.
|
||||
|
||||
@@ -245,3 +245,26 @@ degrade gracefully to their existing node-list fallback). Integrates with
|
||||
| **S5** | **Privacy: messages zeroed in private mode** (Invariant II). When `private_mode?`, every `messages` count (in `total` and all protocol scopes) is **0**, mirroring the `PRIVATE=1` message-API 404 (A2a) so stats never leak message volume that privacy hides. Node counts keep the `CLIENT_HIDDEN` exclusion; **all** metrics honor the node opt-out marker via the per-table opt-out filter (`opt_out_self_filter` for `nodes`; `opt_out_node_id_filter` / `opt_out_node_num_filter` for the message and telemetry-umbrella tables, matching the existing list endpoints). Telemetry/positions/neighbors/traces are not gated by `PRIVATE`, so those counts remain reported. | interview |
|
||||
| **S6** | **`reticulum` is a forward-looking zero stub.** A `reticulum` scope is always emitted with all-zero counts and an in-code `# stub` comment, so the shape extends to future protocols without another break. It adds **no** ingest path (Invariant I), privileges no protocol (Invariant IV), and does **not** enter `KNOWN_PROTOCOLS` (which still gates the `?protocol=` query param at `meshcore` + `meshtastic`). | interview |
|
||||
| **S7** | **One-way federation compatibility (new reads old).** Federation consumers (`crawl.rb`) try the new shape first (`total.nodes[window]`, `meshcore.nodes.day`, `meshtastic.nodes.day`) then fall back to the old shape (`active_nodes[window]`, `meshcore.day`, `meshtastic.day`), then to the existing node-list fallback. Detection is **structural** (key presence/shape) — no in-band version field. New instances read both old and new peers; old instances reading a new peer degrade gracefully (the accepted one-way limit). | interview |
|
||||
|
||||
---
|
||||
|
||||
## Bugfix: API casing consistency
|
||||
|
||||
Removes two casing inconsistencies on the HTTP API, shipped within the same
|
||||
versioned 0.7.0 break. Background: every read collection (`/api/nodes`,
|
||||
`/api/messages`, `/api/positions`, …) and `/api/stats` already emit snake_case;
|
||||
the lone camelCase **read response** was `/version`, and `POST /api/nodes` was the
|
||||
lone camelCase **ingest input** (Meshtastic-shaped). PotatoMesh is multi-protocol
|
||||
and no longer bound to the Meshtastic JSON convention, so the contract is amended
|
||||
to standardise on snake_case while preserving compatibility where it is load-bearing.
|
||||
|
||||
| # | Decision | Source |
|
||||
| --- | --- | --- |
|
||||
| **BF1** | **`/version` response is snake_case.** Top-level `last_node_update` and the `config` block (`site_name`, `map_center` {`lat`,`lon`}, `private_mode`, `instance_domain`, `contact_link`, `contact_link_url`, `max_distance_km`, `refresh_interval_seconds`) replace the prior camelCase keys. A versioned breaking change (0.7.0); consumers are the Flutter app and external clients. | interview |
|
||||
| **BF2** | **`POST /api/nodes` additionally accepts snake_case** node fields (`last_heard`, `user.short_name`/`long_name`/`hw_model`, `device_metrics.battery_level`, `position.location_source`, …) via a **nil-aware** `pick_alias` (a `false` camelCase value is never overridden by a snake_case alias). **Additive** — the Python ingestor's camelCase output keeps working, so no ingestor change is required. | interview |
|
||||
| **BF3** | **The signed federation wire is unchanged** (Invariant III). `/.well-known/potato-mesh` and `/api/instances` keep their camelCase keys (`isPrivate`, `lastUpdateTime`, `nodesCount`, …) because those keys are inside the instance **signature** (`federation/signature.rb`); renaming them would break cross-version signature verification bilaterally. | code |
|
||||
| **BF4** | **Out of scope (deferred).** The Flutter app's `/version` reader (`app/lib/main.dart`) and the server→frontend `data-app-config` DOM channel (`frontend_app_config`) keep camelCase for now and are tracked as separate follow-ups; the frontend dashboard is unaffected (it reads `data-app-config`, not `/version`). | interview |
|
||||
| **BF5** | **`POST /api/instances` accepts snake_case aliases** for its optional fields (`contact_link`, `nodes_count`, `meshcore_nodes_count`, `meshtastic_nodes_count`) in addition to camelCase (third-party / cross-version compat); `id`/`lastUpdateTime`/`isPrivate` were already dual-keyed. The signed canonical payload (camelCase) is unchanged. (I6) | interview |
|
||||
| **BF6** | **Position time is exposed only as `position_time`** (unix int) on GET responses; the redundant ISO twin (`pos_time_iso` on `/api/nodes`, `position_time_iso` on `/api/positions`) is removed — clients format it themselves. (I2) | interview |
|
||||
| **BF7** | **All `POST /api/*` ingest routes return `201 Created`** (was `200`), matching `/api/instances`. The Python ingestor accepts any 2xx (`queue.py` urlopen); the matrix bridge is GET-only. (I3) | interview |
|
||||
| **BF8** | **List POST routes validate the top-level payload.** `/api/messages`, `/positions`, `/telemetry`, `/neighbors`, `/traces` reject a non-Array/non-Hash body with `400 {"error":"invalid payload"}`, matching `/api/nodes` strictness. (I5) | interview |
|
||||
|
||||
@@ -33,7 +33,13 @@ Payload is a mapping keyed by canonical node id, with optional top-level `”ing
|
||||
|
||||
Protocol resolution per-row honours, in order: (1) an explicit per-node `”protocol”` field inside the node entry; (2) the wrapper-level top-level `”protocol”` key; (3) the registered ingestor's protocol (see `POST /api/ingestors`); (4) `”meshtastic”` as the final default. Valid values are `”meshtastic”` and `”meshcore”` — values outside this set fall through to the next source. The wrapper stamp is what the Python ingestor emits unconditionally so the web app classifies records correctly even before the ingestor heartbeat is processed (closes the startup race that misclassified MeshCore placeholders as Meshtastic).
|
||||
|
||||
Node entry fields are “Meshtastic-ish” (camelCase) and may include:
|
||||
Node entry fields are “Meshtastic-ish” (camelCase) and may include the following.
|
||||
**As of 0.7.0 each field is additionally accepted in snake_case** (e.g.
|
||||
`last_heard`, `user.short_name`, `user.hw_model`, `device_metrics.battery_level`,
|
||||
`position.location_source`) so the node ingest contract is no longer
|
||||
Meshtastic-camelCase-only; the existing collector keeps emitting camelCase, which
|
||||
remains accepted. Per-field acceptance is nil-aware, so a camelCase value of
|
||||
`false` is never overridden by a snake_case alias. Fields:
|
||||
|
||||
- `num` (int node number)
|
||||
- `lastHeard` (int unix seconds)
|
||||
@@ -54,7 +60,7 @@ Node entry fields are “Meshtastic-ish” (camelCase) and may include:
|
||||
|
||||
The web application applies the same normalisation as a safety net so legacy ingestors and replayed payloads cannot reintroduce the sentinels, but new ingestors should strip them at the source so the cross-network contract stays clean.
|
||||
|
||||
**Wire-format note for federation peers (issue #782).** GET responses (`/api/nodes`, `/api/positions`) compact sentinel rows by **omitting** the `position_time` and `pos_time_iso` / `position_time_iso` keys rather than emitting them as `0` or `"1970-01-01T00:00:00Z"`. Federation peers consuming this API and any third-party clients SHOULD treat an *absent* `position_time` as "no GPS lock recorded" and not synthesise a zero or epoch value when re-serialising. Older peers that key on `position_time == 0` may need a small adjustment.
|
||||
**Wire-format note for federation peers (issue #782).** Position time is exposed **only** as `position_time` (unix seconds) on GET responses (`/api/nodes`, `/api/positions`); the redundant ISO twin (`pos_time_iso` on `/api/nodes`, `position_time_iso` on `/api/positions`) was **removed in 0.7.0** — clients format `position_time` themselves. Sentinel rows are compacted by **omitting** `position_time` rather than emitting `0` or `"1970-01-01T00:00:00Z"`. Federation peers consuming this API and any third-party clients SHOULD treat an *absent* `position_time` as "no GPS lock recorded" and not synthesise a zero or epoch value when re-serialising. Older peers that key on `position_time == 0` may need a small adjustment.
|
||||
|
||||
#### `POST /api/messages`
|
||||
|
||||
@@ -163,6 +169,8 @@ Heartbeat payload:
|
||||
|
||||
**Protocol propagation**: all event records (`messages`, `positions`, `telemetry`, `traces`, `neighbors`) that reference this ingestor via their `ingestor` field inherit its `protocol` value at write time when no explicit per-record `protocol` stamp is present. Per-record stamps take precedence — the ingestor heartbeat default only kicks in when the per-record field is absent or malformed.
|
||||
|
||||
**POST response & validation (0.7.0).** Every `POST /api/*` ingest route returns `201 Created` with `{"status":"ok"}` on success (`POST /api/instances` returns `{"status":"registered"}`). A batch route (`messages` / `positions` / `telemetry` / `neighbors` / `traces`) accepts either a single record object or an array of them; any other top-level JSON type is rejected with `400 {"error":"invalid payload"}`, matching the `/api/nodes` and `/api/ingestors` object check. Clients should treat any `2xx` as success.
|
||||
|
||||
### GET endpoint filtering
|
||||
|
||||
All collection GET endpoints (`/api/nodes`, `/api/messages`, `/api/positions`, `/api/telemetry`, `/api/traces`, `/api/neighbors`, `/api/ingestors`) accept an optional `?protocol=<value>` query parameter. When present, only records whose `protocol` column matches the given value are returned. The `protocol` field is included in all GET responses.
|
||||
|
||||
@@ -155,14 +155,30 @@ module PotatoMesh
|
||||
# @param n [Hash] node payload extracted from the ingestor.
|
||||
# @param protocol [String] protocol identifier (default +meshtastic+).
|
||||
# @return [void]
|
||||
# Read +hash[primary]+, falling back to the first present alias key. Lets the
|
||||
# node ingest contract accept snake_case fields in addition to the Meshtastic
|
||||
# camelCase the collector emits today; nil-aware so a boolean +false+ from the
|
||||
# primary key is never discarded in favour of an alias.
|
||||
#
|
||||
# @param hash [Object] candidate mapping (ignored unless a Hash).
|
||||
# @param primary [String] preferred key.
|
||||
# @param aliases [Array<String>] fallback keys, tried in order.
|
||||
# @return [Object, nil] first non-nil value, or nil.
|
||||
def pick_alias(hash, primary, *aliases)
|
||||
return nil unless hash.is_a?(Hash)
|
||||
return hash[primary] unless hash[primary].nil?
|
||||
aliases.each { |key| return hash[key] unless hash[key].nil? }
|
||||
nil
|
||||
end
|
||||
|
||||
def upsert_node(db, node_id, n, protocol: "meshtastic")
|
||||
user = n["user"] || {}
|
||||
met = n["deviceMetrics"] || {}
|
||||
met = pick_alias(n, "deviceMetrics", "device_metrics") || {}
|
||||
pos = n["position"] || {}
|
||||
# nil when user info absent; COALESCE in the conflict clause preserves
|
||||
# the stored role rather than overwriting with a default.
|
||||
role = user["role"]
|
||||
lh = coerce_integer(n["lastHeard"])
|
||||
lh = coerce_integer(pick_alias(n, "lastHeard", "last_heard"))
|
||||
now = Time.now.to_i
|
||||
# Issue #782: drop Meshtastic "no GPS lock" sentinels at the write
|
||||
# boundary so neither the nodes row nor downstream readers ever see
|
||||
@@ -187,7 +203,7 @@ module PotatoMesh
|
||||
loc_source = nil
|
||||
else
|
||||
alt = pos["altitude"]
|
||||
loc_source = pos["locationSource"]
|
||||
loc_source = pick_alias(pos, "locationSource", "location_source")
|
||||
end
|
||||
node_num = resolve_node_num(node_id, n)
|
||||
|
||||
@@ -202,7 +218,7 @@ module PotatoMesh
|
||||
# Synthetic flag: true for placeholder nodes created from channel message
|
||||
# sender names before the real contact advertisement is received.
|
||||
synthetic = user["synthetic"] ? 1 : 0
|
||||
long_name = user["longName"]
|
||||
long_name = pick_alias(user, "longName", "long_name")
|
||||
|
||||
# If the incoming long name is a generic placeholder, prefer any real
|
||||
# name already on record so we never stomp known data with fallback
|
||||
@@ -224,23 +240,23 @@ module PotatoMesh
|
||||
row = [
|
||||
node_id,
|
||||
node_num,
|
||||
user["shortName"],
|
||||
pick_alias(user, "shortName", "short_name"),
|
||||
long_name,
|
||||
user["macaddr"],
|
||||
user["hwModel"] || n["hwModel"],
|
||||
pick_alias(user, "hwModel", "hw_model") || pick_alias(n, "hwModel", "hw_model"),
|
||||
role,
|
||||
user["publicKey"],
|
||||
coerce_bool(user["isUnmessagable"]),
|
||||
coerce_bool(n["isFavorite"]),
|
||||
n["hopsAway"],
|
||||
pick_alias(user, "publicKey", "public_key"),
|
||||
coerce_bool(pick_alias(user, "isUnmessagable", "is_unmessagable")),
|
||||
coerce_bool(pick_alias(n, "isFavorite", "is_favorite")),
|
||||
pick_alias(n, "hopsAway", "hops_away"),
|
||||
n["snr"],
|
||||
lh,
|
||||
lh,
|
||||
met["batteryLevel"],
|
||||
pick_alias(met, "batteryLevel", "battery_level"),
|
||||
met["voltage"],
|
||||
met["channelUtilization"],
|
||||
met["airUtilTx"],
|
||||
met["uptimeSeconds"],
|
||||
pick_alias(met, "channelUtilization", "channel_utilization"),
|
||||
pick_alias(met, "airUtilTx", "air_util_tx"),
|
||||
pick_alias(met, "uptimeSeconds", "uptime_seconds"),
|
||||
pt,
|
||||
loc_source,
|
||||
coerce_integer(
|
||||
|
||||
@@ -67,7 +67,7 @@ module PotatoMesh
|
||||
|
||||
position_time = coerce_positive_or_nil(r["position_time"], ceiling: now)
|
||||
r["position_time"] = position_time
|
||||
r["position_time_iso"] = Time.at(position_time).utc.iso8601 if position_time
|
||||
# I2: only position_time (unix int) is emitted; no ISO twin.
|
||||
|
||||
r["precision_bits"] = coerce_integer(r["precision_bits"])
|
||||
r["sats_in_view"] = coerce_integer(r["sats_in_view"])
|
||||
|
||||
@@ -210,7 +210,8 @@ module PotatoMesh
|
||||
r["last_heard"] = lh
|
||||
r["position_time"] = pt
|
||||
r["last_seen_iso"] = Time.at(lh).utc.iso8601 if lh
|
||||
r["pos_time_iso"] = Time.at(pt).utc.iso8601 if pt
|
||||
# I2: position_time (unix int) is the sole position-time key; the
|
||||
# redundant ISO twin (pos_time_iso / position_time_iso) is not emitted.
|
||||
pb = r["precision_bits"]
|
||||
r["precision_bits"] = pb.to_i if pb
|
||||
end
|
||||
|
||||
@@ -56,21 +56,21 @@ module PotatoMesh
|
||||
payload = {
|
||||
name: sanitized_site_name,
|
||||
version: app_constant(:APP_VERSION),
|
||||
lastNodeUpdate: last_update,
|
||||
last_node_update: last_update,
|
||||
config: {
|
||||
siteName: sanitized_site_name,
|
||||
site_name: sanitized_site_name,
|
||||
channel: sanitized_channel,
|
||||
frequency: sanitized_frequency,
|
||||
contactLink: sanitized_contact_link,
|
||||
contactLinkUrl: sanitized_contact_link_url,
|
||||
refreshIntervalSeconds: PotatoMesh::Config.refresh_interval_seconds,
|
||||
mapCenter: {
|
||||
contact_link: sanitized_contact_link,
|
||||
contact_link_url: sanitized_contact_link_url,
|
||||
refresh_interval_seconds: PotatoMesh::Config.refresh_interval_seconds,
|
||||
map_center: {
|
||||
lat: PotatoMesh::Config.map_center_lat,
|
||||
lon: PotatoMesh::Config.map_center_lon,
|
||||
},
|
||||
maxDistanceKm: PotatoMesh::Config.max_distance_km,
|
||||
instanceDomain: app_constant(:INSTANCE_DOMAIN),
|
||||
privateMode: private_mode?,
|
||||
max_distance_km: PotatoMesh::Config.max_distance_km,
|
||||
instance_domain: app_constant(:INSTANCE_DOMAIN),
|
||||
private_mode: private_mode?,
|
||||
},
|
||||
}
|
||||
payload.to_json
|
||||
|
||||
@@ -53,6 +53,7 @@ module PotatoMesh
|
||||
end
|
||||
PotatoMesh::App::Prometheus::NODES_GAUGE.set(query_nodes(1000).length)
|
||||
PotatoMesh::App::ApiCache.invalidate_prefix("api:nodes:", "api:stats:")
|
||||
status 201
|
||||
{ status: "ok" }.to_json
|
||||
ensure
|
||||
db&.close
|
||||
@@ -66,6 +67,9 @@ module PotatoMesh
|
||||
rescue JSON::ParserError
|
||||
halt 400, { error: "invalid JSON" }.to_json
|
||||
end
|
||||
unless data.is_a?(Array) || data.is_a?(Hash)
|
||||
halt 400, { error: "invalid payload" }.to_json
|
||||
end
|
||||
messages = data.is_a?(Array) ? data : [data]
|
||||
halt 400, { error: "too many messages" }.to_json if messages.size > 1000
|
||||
db = open_database
|
||||
@@ -74,6 +78,7 @@ module PotatoMesh
|
||||
insert_message(db, msg, protocol_cache: protocol_cache)
|
||||
end
|
||||
PotatoMesh::App::ApiCache.invalidate_prefix("api:messages:", "api:stats:")
|
||||
status 201
|
||||
{ status: "ok" }.to_json
|
||||
ensure
|
||||
db&.close
|
||||
@@ -94,6 +99,7 @@ module PotatoMesh
|
||||
stored = upsert_ingestor(db, payload)
|
||||
halt 400, { error: "invalid payload" }.to_json unless stored
|
||||
PotatoMesh::App::ApiCache.invalidate_prefix("api:ingestors:")
|
||||
status 201
|
||||
{ status: "ok" }.to_json
|
||||
ensure
|
||||
db&.close
|
||||
@@ -147,10 +153,13 @@ module PotatoMesh
|
||||
raw_private = payload.key?("isPrivate") ? payload["isPrivate"] : payload["is_private"]
|
||||
is_private = coerce_boolean(raw_private)
|
||||
signature = string_or_nil(payload["signature"])
|
||||
contact_link = string_or_nil(payload["contactLink"])
|
||||
nodes_count = coerce_integer(payload["nodesCount"])
|
||||
meshcore_nodes_count = coerce_integer(payload["meshcoreNodesCount"])
|
||||
meshtastic_nodes_count = coerce_integer(payload["meshtasticNodesCount"])
|
||||
# Accept either casing for third-party / cross-version compatibility
|
||||
# (the existing camelCase keys plus their snake_case aliases). +id+,
|
||||
# +lastUpdateTime+, and +isPrivate+ are already dual-keyed above.
|
||||
contact_link = string_or_nil(payload["contactLink"] || payload["contact_link"])
|
||||
nodes_count = coerce_integer(payload["nodesCount"] || payload["nodes_count"])
|
||||
meshcore_nodes_count = coerce_integer(payload["meshcoreNodesCount"] || payload["meshcore_nodes_count"])
|
||||
meshtastic_nodes_count = coerce_integer(payload["meshtasticNodesCount"] || payload["meshtastic_nodes_count"])
|
||||
|
||||
attributes = {
|
||||
id: id,
|
||||
@@ -352,6 +361,9 @@ module PotatoMesh
|
||||
rescue JSON::ParserError
|
||||
halt 400, { error: "invalid JSON" }.to_json
|
||||
end
|
||||
unless data.is_a?(Array) || data.is_a?(Hash)
|
||||
halt 400, { error: "invalid payload" }.to_json
|
||||
end
|
||||
positions = data.is_a?(Array) ? data : [data]
|
||||
halt 400, { error: "too many positions" }.to_json if positions.size > 1000
|
||||
db = open_database
|
||||
@@ -360,6 +372,7 @@ module PotatoMesh
|
||||
insert_position(db, pos, protocol_cache: protocol_cache)
|
||||
end
|
||||
PotatoMesh::App::ApiCache.invalidate_prefix("api:positions:", "api:nodes:", "api:stats:")
|
||||
status 201
|
||||
{ status: "ok" }.to_json
|
||||
ensure
|
||||
db&.close
|
||||
@@ -373,6 +386,9 @@ module PotatoMesh
|
||||
rescue JSON::ParserError
|
||||
halt 400, { error: "invalid JSON" }.to_json
|
||||
end
|
||||
unless data.is_a?(Array) || data.is_a?(Hash)
|
||||
halt 400, { error: "invalid payload" }.to_json
|
||||
end
|
||||
neighbor_payloads = data.is_a?(Array) ? data : [data]
|
||||
halt 400, { error: "too many neighbor packets" }.to_json if neighbor_payloads.size > 1000
|
||||
db = open_database
|
||||
@@ -381,6 +397,7 @@ module PotatoMesh
|
||||
insert_neighbors(db, packet, protocol_cache: protocol_cache)
|
||||
end
|
||||
PotatoMesh::App::ApiCache.invalidate_prefix("api:neighbors:", "api:stats:")
|
||||
status 201
|
||||
{ status: "ok" }.to_json
|
||||
ensure
|
||||
db&.close
|
||||
@@ -394,6 +411,9 @@ module PotatoMesh
|
||||
rescue JSON::ParserError
|
||||
halt 400, { error: "invalid JSON" }.to_json
|
||||
end
|
||||
unless data.is_a?(Array) || data.is_a?(Hash)
|
||||
halt 400, { error: "invalid payload" }.to_json
|
||||
end
|
||||
telemetry_packets = data.is_a?(Array) ? data : [data]
|
||||
halt 400, { error: "too many telemetry packets" }.to_json if telemetry_packets.size > 1000
|
||||
db = open_database
|
||||
@@ -402,6 +422,7 @@ module PotatoMesh
|
||||
insert_telemetry(db, packet, protocol_cache: protocol_cache)
|
||||
end
|
||||
PotatoMesh::App::ApiCache.invalidate_prefix("api:telemetry:", "api:stats:")
|
||||
status 201
|
||||
{ status: "ok" }.to_json
|
||||
ensure
|
||||
db&.close
|
||||
@@ -415,6 +436,9 @@ module PotatoMesh
|
||||
rescue JSON::ParserError
|
||||
halt 400, { error: "invalid JSON" }.to_json
|
||||
end
|
||||
unless data.is_a?(Array) || data.is_a?(Hash)
|
||||
halt 400, { error: "invalid payload" }.to_json
|
||||
end
|
||||
trace_packets = data.is_a?(Array) ? data : [data]
|
||||
halt 400, { error: "too many traces" }.to_json if trace_packets.size > 1000
|
||||
db = open_database
|
||||
@@ -423,6 +447,7 @@ module PotatoMesh
|
||||
insert_trace(db, packet, protocol_cache: protocol_cache)
|
||||
end
|
||||
PotatoMesh::App::ApiCache.invalidate_prefix("api:traces:", "api:stats:")
|
||||
status 201
|
||||
{ status: "ok" }.to_json
|
||||
ensure
|
||||
db&.close
|
||||
|
||||
+162
-73
@@ -323,7 +323,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
nodes_fixture.each do |node|
|
||||
payload = { node["node_id"] => build_node_payload(node) }
|
||||
post "/api/nodes", payload.to_json, auth_headers
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.status).to eq(201)
|
||||
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
|
||||
end
|
||||
end
|
||||
@@ -335,7 +335,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
messages_fixture.each do |message|
|
||||
payload = message.reject { |key, _| key == "node" }
|
||||
post "/api/messages", payload.to_json, auth_headers
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.status).to eq(201)
|
||||
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
|
||||
end
|
||||
end
|
||||
@@ -347,7 +347,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
def import_positions_fixture(limit: positions_fixture.size)
|
||||
positions_fixture.first(limit).each do |position|
|
||||
post "/api/positions", position.to_json, auth_headers
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.status).to eq(201)
|
||||
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
|
||||
end
|
||||
end
|
||||
@@ -1703,6 +1703,32 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
end
|
||||
end
|
||||
|
||||
it "accepts snake_case optional fields on POST /api/instances" do
|
||||
contact = "#room:example.org"
|
||||
signed_attrs = instance_attributes.merge(contact_link: contact)
|
||||
signature = Base64.strict_encode64(
|
||||
instance_key.sign(OpenSSL::Digest::SHA256.new, canonical_instance_payload(signed_attrs)),
|
||||
)
|
||||
# snake_case contact_link plus the existing camelCase keys — third-party
|
||||
# callers may send either casing (the camelCase keys stay accepted too).
|
||||
snake_payload = instance_payload.merge(
|
||||
"contact_link" => contact,
|
||||
"signature" => signature,
|
||||
)
|
||||
|
||||
post "/api/instances", snake_payload.to_json, { "CONTENT_TYPE" => "application/json" }
|
||||
|
||||
expect(last_response.status).to eq(201)
|
||||
with_db(readonly: true) do |db|
|
||||
db.results_as_hash = true
|
||||
row = db.get_first_row(
|
||||
"SELECT contact_link FROM instances WHERE id = ?",
|
||||
[instance_attributes[:id]],
|
||||
)
|
||||
expect(row["contact_link"]).to eq(contact)
|
||||
end
|
||||
end
|
||||
|
||||
it "stores a federated instance when validation succeeds" do
|
||||
post "/api/instances", instance_payload.to_json, { "CONTENT_TYPE" => "application/json" }
|
||||
|
||||
@@ -3030,7 +3056,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
|
||||
post "/api/nodes", payload.to_json, auth_headers
|
||||
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.status).to eq(201)
|
||||
|
||||
with_db(readonly: true) do |db|
|
||||
db.results_as_hash = true
|
||||
@@ -3065,7 +3091,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
|
||||
post "/api/nodes", payload.to_json, auth_headers
|
||||
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.status).to eq(201)
|
||||
|
||||
with_db(readonly: true) do |db|
|
||||
db.results_as_hash = true
|
||||
@@ -3099,7 +3125,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
|
||||
post "/api/nodes", nodeinfo_payload.to_json, auth_headers
|
||||
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.status).to eq(201)
|
||||
|
||||
with_db(readonly: true) do |db|
|
||||
db.results_as_hash = true
|
||||
@@ -3159,7 +3185,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
|
||||
post "/api/nodes", payload.to_json, auth_headers
|
||||
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.status).to eq(201)
|
||||
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
|
||||
|
||||
with_db(readonly: true) do |db|
|
||||
@@ -3194,7 +3220,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
|
||||
post "/api/nodes", payload.to_json, auth_headers
|
||||
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.status).to eq(201)
|
||||
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
|
||||
expect(call_count).to be >= 2
|
||||
|
||||
@@ -3242,21 +3268,21 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
it "does not overwrite a real name with a meshtastic generic fallback" do
|
||||
seed_node(long_name: "Peter's Node")
|
||||
post_long_name("Meshtastic BEEF")
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.status).to eq(201)
|
||||
expect(stored_long_name).to eq("Peter's Node")
|
||||
end
|
||||
|
||||
it "writes a generic fallback when no name is on record" do
|
||||
seed_node
|
||||
post_long_name("Meshtastic BEEF")
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.status).to eq(201)
|
||||
expect(stored_long_name).to eq("Meshtastic BEEF")
|
||||
end
|
||||
|
||||
it "overwrites a generic fallback with a real name" do
|
||||
seed_node(long_name: "Meshtastic BEEF")
|
||||
post_long_name("Peter's Node")
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.status).to eq(201)
|
||||
expect(stored_long_name).to eq("Peter's Node")
|
||||
end
|
||||
|
||||
@@ -3268,7 +3294,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
auth_headers
|
||||
seed_node(long_name: "Peter's Node")
|
||||
post_long_name("Meshcore BEEF", ingestor: ingestor_id)
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.status).to eq(201)
|
||||
expect(stored_long_name).to eq("Peter's Node")
|
||||
end
|
||||
end
|
||||
@@ -3416,9 +3442,9 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
|
||||
def post_twice_for_ingestor(endpoint, first_payload, second_payload)
|
||||
post endpoint, first_payload.to_json, auth_headers
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.status).to eq(201)
|
||||
post endpoint, second_payload.to_json, auth_headers
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.status).to eq(201)
|
||||
end
|
||||
|
||||
it "persists messages from fixture data" do
|
||||
@@ -3484,9 +3510,9 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
}
|
||||
|
||||
post "/api/messages", parent_payload.to_json, auth_headers
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.status).to eq(201)
|
||||
post "/api/messages", reaction_payload.to_json, auth_headers
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.status).to eq(201)
|
||||
|
||||
with_db(readonly: true) do |db|
|
||||
db.results_as_hash = true
|
||||
@@ -3548,7 +3574,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
|
||||
post "/api/messages", payload.to_json, auth_headers
|
||||
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.status).to eq(201)
|
||||
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
|
||||
|
||||
with_db(readonly: true) do |db|
|
||||
@@ -3583,7 +3609,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
|
||||
post "/api/messages", payload.to_json, auth_headers
|
||||
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.status).to eq(201)
|
||||
with_db(readonly: true) do |db|
|
||||
count = db.get_first_value("SELECT COUNT(*) FROM nodes WHERE node_id = '!ffffffff'")
|
||||
expect(count).to eq(0)
|
||||
@@ -3606,7 +3632,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
|
||||
post "/api/messages", payload.to_json, auth_headers
|
||||
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.status).to eq(201)
|
||||
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
|
||||
|
||||
with_db(readonly: true) do |db|
|
||||
@@ -3675,7 +3701,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
}
|
||||
|
||||
post "/api/nodes", node_payload.to_json, auth_headers
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.status).to eq(201)
|
||||
|
||||
rx_time = reference_time.to_i - 120
|
||||
position_time = rx_time - 30
|
||||
@@ -3707,7 +3733,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
|
||||
post "/api/positions", position_payload.to_json, auth_headers
|
||||
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.status).to eq(201)
|
||||
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
|
||||
|
||||
with_db(readonly: true) do |db|
|
||||
@@ -3771,7 +3797,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
|
||||
post "/api/positions", payload.to_json, auth_headers
|
||||
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.status).to eq(201)
|
||||
|
||||
with_db(readonly: true) do |db|
|
||||
db.results_as_hash = true
|
||||
@@ -3800,7 +3826,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
|
||||
post "/api/positions", payload.to_json, auth_headers
|
||||
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.status).to eq(201)
|
||||
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
|
||||
|
||||
with_db(readonly: true) do |db|
|
||||
@@ -3864,7 +3890,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
|
||||
post "/api/positions", payload.to_json, auth_headers
|
||||
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.status).to eq(201)
|
||||
|
||||
with_db(readonly: true) do |db|
|
||||
db.results_as_hash = true
|
||||
@@ -3940,7 +3966,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
|
||||
post "/api/neighbors", payload.to_json, auth_headers
|
||||
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.status).to eq(201)
|
||||
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
|
||||
|
||||
with_db(readonly: true) do |db|
|
||||
@@ -4014,7 +4040,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
|
||||
post "/api/neighbors", payload.to_json, auth_headers
|
||||
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.status).to eq(201)
|
||||
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
|
||||
|
||||
with_db(readonly: true) do |db|
|
||||
@@ -4037,7 +4063,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
|
||||
post "/api/neighbors", payload.to_json, auth_headers
|
||||
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.status).to eq(201)
|
||||
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
|
||||
|
||||
with_db(readonly: true) do |db|
|
||||
@@ -4075,9 +4101,9 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
}
|
||||
|
||||
post "/api/neighbors", seed_payload.to_json, auth_headers
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.status).to eq(201)
|
||||
post "/api/neighbors", empty_payload.to_json, auth_headers
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.status).to eq(201)
|
||||
|
||||
with_db(readonly: true) do |db|
|
||||
remaining = db.get_first_value(SELECT_NEIGHBOR_COUNT_BY_NODE_SQL, [NEIGHBOR_EMPTY_UPDATE_ROOT_ID])
|
||||
@@ -4133,9 +4159,9 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
}
|
||||
|
||||
post "/api/neighbors", initial.to_json, auth_headers
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.status).to eq(201)
|
||||
post "/api/neighbors", update.to_json, auth_headers
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.status).to eq(201)
|
||||
|
||||
with_db(readonly: true) do |db|
|
||||
db.results_as_hash = true
|
||||
@@ -4166,9 +4192,9 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
}
|
||||
|
||||
post "/api/neighbors", initial.to_json, auth_headers
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.status).to eq(201)
|
||||
post "/api/neighbors", update.to_json, auth_headers
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.status).to eq(201)
|
||||
|
||||
with_db(readonly: true) do |db|
|
||||
count = db.get_first_value(
|
||||
@@ -4207,7 +4233,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
|
||||
post "/api/neighbors", payload.to_json, auth_headers
|
||||
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.status).to eq(201)
|
||||
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
|
||||
|
||||
with_db(readonly: true) do |db|
|
||||
@@ -4226,7 +4252,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
|
||||
post "/api/telemetry", payload.to_json, auth_headers
|
||||
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.status).to eq(201)
|
||||
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
|
||||
|
||||
with_db(readonly: true) do |db|
|
||||
@@ -4408,7 +4434,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
|
||||
post "/api/telemetry", payload.to_json, auth_headers
|
||||
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.status).to eq(201)
|
||||
expect_stored_telemetry_type(24_001, "device")
|
||||
end
|
||||
|
||||
@@ -4424,7 +4450,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
|
||||
post "/api/telemetry", payload.to_json, auth_headers
|
||||
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.status).to eq(201)
|
||||
expect_stored_telemetry_type(24_002, "environment")
|
||||
end
|
||||
|
||||
@@ -4442,7 +4468,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
|
||||
post "/api/telemetry", payload.to_json, auth_headers
|
||||
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.status).to eq(201)
|
||||
expect_stored_telemetry_type(24_003, "power")
|
||||
end
|
||||
|
||||
@@ -4458,7 +4484,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
|
||||
post "/api/telemetry", payload.to_json, auth_headers
|
||||
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.status).to eq(201)
|
||||
|
||||
get "/api/telemetry/!teltype04", {}, auth_headers
|
||||
|
||||
@@ -4481,7 +4507,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
|
||||
post "/api/telemetry", payload.to_json, auth_headers
|
||||
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.status).to eq(201)
|
||||
expect_stored_telemetry_type(24_005, "air_quality")
|
||||
end
|
||||
|
||||
@@ -4498,7 +4524,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
|
||||
post "/api/telemetry", payload.to_json, auth_headers
|
||||
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.status).to eq(201)
|
||||
# Invalid explicit type must be discarded; device_metrics inference takes over.
|
||||
expect_stored_telemetry_type(24_006, "device")
|
||||
end
|
||||
@@ -4524,7 +4550,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
|
||||
post "/api/traces", payload.to_json, auth_headers
|
||||
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.status).to eq(201)
|
||||
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
|
||||
|
||||
with_db(readonly: true) do |db|
|
||||
@@ -4594,7 +4620,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
|
||||
post "/api/traces", payload.to_json, auth_headers
|
||||
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.status).to eq(201)
|
||||
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
|
||||
|
||||
with_db(readonly: true) do |db|
|
||||
@@ -4685,7 +4711,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
}
|
||||
|
||||
post "/api/nodes", node_payload.to_json, auth_headers
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.status).to eq(201)
|
||||
|
||||
messages_payload = [
|
||||
{
|
||||
@@ -4705,7 +4731,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
|
||||
post "/api/messages", messages_payload.to_json, auth_headers
|
||||
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.status).to eq(201)
|
||||
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
|
||||
|
||||
with_db(readonly: true) do |db|
|
||||
@@ -4785,9 +4811,9 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
receiver_payload["num"] = receiver_num
|
||||
|
||||
post "/api/nodes", { sender_id => sender_payload }.to_json, auth_headers
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.status).to eq(201)
|
||||
post "/api/nodes", { receiver_id => receiver_payload }.to_json, auth_headers
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.status).to eq(201)
|
||||
|
||||
encrypted_b64 = Base64.strict_encode64("secret message")
|
||||
payload = {
|
||||
@@ -4806,7 +4832,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
|
||||
post "/api/messages", payload.to_json, auth_headers
|
||||
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.status).to eq(201)
|
||||
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
|
||||
|
||||
with_db(readonly: true) do |db|
|
||||
@@ -4885,7 +4911,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
|
||||
post "/api/messages", payload.to_json, auth_headers
|
||||
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.status).to eq(201)
|
||||
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
|
||||
|
||||
with_db(readonly: true) do |db|
|
||||
@@ -4965,7 +4991,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
|
||||
post "/api/messages", payload.to_json, auth_headers
|
||||
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.status).to eq(201)
|
||||
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
|
||||
|
||||
with_db(readonly: true) do |db|
|
||||
@@ -5059,7 +5085,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
|
||||
post "/api/messages", payload.to_json, auth_headers
|
||||
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.status).to eq(201)
|
||||
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
|
||||
|
||||
with_db(readonly: true) do |db|
|
||||
@@ -5213,7 +5239,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
|
||||
post "/api/messages", payload.to_json, auth_headers
|
||||
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.status).to eq(201)
|
||||
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
|
||||
|
||||
with_db(readonly: true) do |db|
|
||||
@@ -5955,7 +5981,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
|
||||
post "/api/messages", payload.to_json, auth_headers
|
||||
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.status).to eq(201)
|
||||
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
|
||||
|
||||
with_db(readonly: true) do |db|
|
||||
@@ -5979,7 +6005,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
|
||||
post "/api/messages", payload.to_json, auth_headers
|
||||
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.status).to eq(201)
|
||||
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
|
||||
|
||||
with_db(readonly: true) do |db|
|
||||
@@ -6018,7 +6044,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
|
||||
post "/api/messages", base_payload.merge("from_id" => nil).to_json, auth_headers
|
||||
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.status).to eq(201)
|
||||
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
|
||||
|
||||
with_db(readonly: true) do |db|
|
||||
@@ -6040,7 +6066,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
"from_id" => " ",
|
||||
).to_json, auth_headers
|
||||
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.status).to eq(201)
|
||||
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
|
||||
|
||||
with_db(readonly: true) do |db|
|
||||
@@ -6061,7 +6087,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
"from" => "!spec-sender",
|
||||
).to_json, auth_headers
|
||||
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.status).to eq(201)
|
||||
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
|
||||
|
||||
with_db(readonly: true) do |db|
|
||||
@@ -6106,13 +6132,12 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
end
|
||||
|
||||
raw_position_time = node["position_time"]
|
||||
# I2: the redundant position ISO key is dropped — only `position_time`
|
||||
# (unix int) is emitted; clients format it. The 0-sentinel still yields no
|
||||
# `position_time` at all (issue #782, `coerce_positive_or_nil`).
|
||||
expect(actual_row).not_to have_key("pos_time_iso")
|
||||
if raw_position_time.is_a?(Numeric) && raw_position_time > 0
|
||||
expected_pos_iso = Time.at(raw_position_time).utc.iso8601
|
||||
expect(actual_row["pos_time_iso"]).to eq(expected_pos_iso)
|
||||
else
|
||||
# Sentinel `position_time = 0` must not emit "1970-01-01T00:00:00Z";
|
||||
# see `coerce_positive_or_nil` in queries/common.rb (issue #782).
|
||||
expect(actual_row).not_to have_key("pos_time_iso")
|
||||
expect(actual_row["position_time"]).to eq(raw_position_time)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -6275,6 +6300,69 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
end
|
||||
end
|
||||
|
||||
describe "API casing consistency" do
|
||||
it "exposes the /version config block in snake_case" do
|
||||
get "/version"
|
||||
expect(last_response).to be_ok
|
||||
payload = JSON.parse(last_response.body)
|
||||
cfg = payload["config"]
|
||||
expect(cfg).to include(
|
||||
"site_name", "map_center", "private_mode", "instance_domain",
|
||||
"contact_link", "contact_link_url", "max_distance_km",
|
||||
"refresh_interval_seconds"
|
||||
)
|
||||
expect(cfg["map_center"]).to include("lat", "lon")
|
||||
# The pre-0.7.0 camelCase config keys are gone (breaking /version change).
|
||||
%w[siteName mapCenter privateMode instanceDomain contactLink contactLinkUrl maxDistanceKm refreshIntervalSeconds].each do |camel|
|
||||
expect(cfg).not_to have_key(camel)
|
||||
end
|
||||
expect(payload).to have_key("last_node_update")
|
||||
expect(payload).not_to have_key("lastNodeUpdate")
|
||||
end
|
||||
|
||||
it "accepts snake_case node fields on POST /api/nodes alongside camelCase" do
|
||||
now = reference_time.to_i
|
||||
payload = {
|
||||
"!aabbccdd" => {
|
||||
"num" => 0xAABBCCDD,
|
||||
"last_heard" => now,
|
||||
"user" => { "short_name" => "SNAK", "long_name" => "Snake Case Node", "hw_model" => "TBEAM" },
|
||||
"device_metrics" => { "battery_level" => 77 },
|
||||
"position" => { "latitude" => 52.0, "longitude" => 13.0 },
|
||||
},
|
||||
}
|
||||
post "/api/nodes", payload.to_json, auth_headers
|
||||
expect(last_response.status).to eq(201)
|
||||
|
||||
get "/api/nodes?limit=1000"
|
||||
row = JSON.parse(last_response.body).find { |r| r["node_id"] == "!aabbccdd" }
|
||||
expect(row).not_to be_nil
|
||||
expect(row["short_name"]).to eq("SNAK")
|
||||
expect(row["long_name"]).to eq("Snake Case Node")
|
||||
expect(row["hw_model"]).to eq("TBEAM")
|
||||
expect(row["battery_level"]).to eq(77)
|
||||
end
|
||||
end
|
||||
|
||||
describe "POST ingest status codes" do
|
||||
it "returns 201 Created on a successful node ingest (consistent with /api/instances)" do
|
||||
payload = { "!c0debabe" => { "num" => 0xC0DEBABE, "last_heard" => reference_time.to_i } }
|
||||
post "/api/nodes", payload.to_json, auth_headers
|
||||
expect(last_response.status).to eq(201)
|
||||
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
|
||||
end
|
||||
end
|
||||
|
||||
describe "POST payload validation" do
|
||||
%w[messages positions telemetry neighbors traces].each do |endpoint|
|
||||
it "rejects a non-array/non-object payload on /api/#{endpoint} with 400" do
|
||||
post "/api/#{endpoint}", '"garbage"', auth_headers
|
||||
expect(last_response.status).to eq(400)
|
||||
expect(JSON.parse(last_response.body)).to eq("error" => "invalid payload")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "GET /api/stats" do
|
||||
it "returns exact SQL-backed activity counts with per-protocol breakdowns" do
|
||||
clear_database
|
||||
@@ -6515,7 +6603,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
}
|
||||
|
||||
post "/api/messages", payload.to_json, auth_headers
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.status).to eq(201)
|
||||
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
|
||||
|
||||
get "/api/messages"
|
||||
@@ -6844,7 +6932,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
},
|
||||
}
|
||||
post "/api/nodes", payload.to_json, auth_headers
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.status).to eq(201)
|
||||
|
||||
# The row exists in the database — opt-out is a display-time filter,
|
||||
# not an ingestion-time refusal.
|
||||
@@ -6893,7 +6981,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
"payload_b64" => "AQI=",
|
||||
}
|
||||
post "/api/positions", payload.to_json, auth_headers
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.status).to eq(201)
|
||||
end
|
||||
|
||||
get "/api/positions?limit=1"
|
||||
@@ -6907,7 +6995,8 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
expect(entry["rx_time"]).to eq(rx_times.last)
|
||||
expect(entry["rx_iso"]).to eq(Time.at(rx_times.last).utc.iso8601)
|
||||
expect(entry["position_time"]).to eq(rx_times.last - 5)
|
||||
expect(entry["position_time_iso"]).to eq(Time.at(rx_times.last - 5).utc.iso8601)
|
||||
# I2: position ISO key dropped — only `position_time` (unix int) is emitted.
|
||||
expect(entry).not_to have_key("position_time_iso")
|
||||
expect(entry["latitude"]).to eq(53.0)
|
||||
expect(entry["longitude"]).to eq(14.0)
|
||||
expect(entry["location_source"]).to eq("LOC_TEST")
|
||||
@@ -7170,7 +7259,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
describe "GET /api/telemetry" do
|
||||
it "returns stored telemetry ordered by receive time" do
|
||||
post "/api/telemetry", telemetry_fixture.to_json, auth_headers
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.status).to eq(201)
|
||||
|
||||
get "/api/telemetry?limit=2"
|
||||
|
||||
@@ -7392,7 +7481,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
describe "GET /api/telemetry/aggregated" do
|
||||
it "returns aggregated telemetry buckets for the requested interval" do
|
||||
post "/api/telemetry", telemetry_fixture.to_json, auth_headers
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.status).to eq(201)
|
||||
|
||||
get "/api/telemetry/aggregated?windowSeconds=86400&bucketSeconds=300"
|
||||
|
||||
@@ -7441,7 +7530,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
|
||||
it "applies default window and bucket sizes when parameters are omitted" do
|
||||
post "/api/telemetry", telemetry_fixture.to_json, auth_headers
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.status).to eq(201)
|
||||
|
||||
get "/api/telemetry/aggregated"
|
||||
|
||||
@@ -7590,7 +7679,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
it "returns stored traces ordered by receive time" do
|
||||
clear_database
|
||||
post "/api/traces", trace_fixture.to_json, auth_headers
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.status).to eq(201)
|
||||
|
||||
get "/api/traces"
|
||||
|
||||
@@ -7615,7 +7704,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
it "filters traces by node reference across sources" do
|
||||
clear_database
|
||||
post "/api/traces", trace_fixture.to_json, auth_headers
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.status).to eq(201)
|
||||
|
||||
get "/api/traces/#{trace_fixture.first["src"]}"
|
||||
|
||||
@@ -7650,7 +7739,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
]
|
||||
|
||||
post "/api/traces", payload.to_json, auth_headers
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.status).to eq(201)
|
||||
|
||||
get "/api/traces"
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ RSpec.describe "Ingestor endpoints" do
|
||||
payload = ingestor_payload
|
||||
post "/api/ingestors", payload.to_json, auth_headers
|
||||
|
||||
expect(last_response.status).to eq(200)
|
||||
expect(last_response.status).to eq(201)
|
||||
|
||||
newer_last_seen = payload[:last_seen_time] + 3_600
|
||||
older_start = payload[:start_time] - 500
|
||||
@@ -86,7 +86,7 @@ RSpec.describe "Ingestor endpoints" do
|
||||
payload.merge(last_seen_time: newer_last_seen, start_time: older_start).to_json,
|
||||
auth_headers
|
||||
|
||||
expect(last_response.status).to eq(200)
|
||||
expect(last_response.status).to eq(201)
|
||||
with_db(readonly: true) do |db|
|
||||
row = db.get_first_row(
|
||||
"SELECT node_id, start_time, last_seen_time, version, lora_freq, modem_preset FROM ingestors WHERE node_id = ?",
|
||||
@@ -199,13 +199,13 @@ RSpec.describe "Ingestor endpoints" do
|
||||
# so the request succeeds rather than returning 400.
|
||||
post "/api/ingestors", ingestor_payload(start_time: nil).to_json, auth_headers
|
||||
|
||||
expect(last_response.status).to eq(200)
|
||||
expect(last_response.status).to eq(201)
|
||||
end
|
||||
|
||||
it "accepts a payload where last_seen_time is missing (falls back to start_time)" do
|
||||
post "/api/ingestors", ingestor_payload(last_seen_time: nil).to_json, auth_headers
|
||||
|
||||
expect(last_response.status).to eq(200)
|
||||
expect(last_response.status).to eq(201)
|
||||
end
|
||||
|
||||
it "rejects a payload where node_id is missing" do
|
||||
@@ -219,7 +219,7 @@ RSpec.describe "Ingestor endpoints" do
|
||||
# succeeds and stores lora_freq as NULL.
|
||||
post "/api/ingestors", ingestor_payload(lora_freq: "not-a-number").to_json, auth_headers
|
||||
|
||||
expect(last_response.status).to eq(200)
|
||||
expect(last_response.status).to eq(201)
|
||||
end
|
||||
|
||||
it "returns 200 when optional lora_freq is absent entirely" do
|
||||
@@ -227,7 +227,7 @@ RSpec.describe "Ingestor endpoints" do
|
||||
payload.delete(:lora_freq)
|
||||
post "/api/ingestors", payload.to_json, auth_headers
|
||||
|
||||
expect(last_response.status).to eq(200)
|
||||
expect(last_response.status).to eq(201)
|
||||
end
|
||||
|
||||
it "returns 403 with the wrong bearer token" do
|
||||
|
||||
+21
-21
@@ -98,7 +98,7 @@ RSpec.describe "Multi-protocol support" do
|
||||
it "stores protocol when provided" do
|
||||
register_ingestor(MESHCORE_INGESTOR_ID, protocol: "meshcore")
|
||||
|
||||
expect(last_response.status).to eq(200)
|
||||
expect(last_response.status).to eq(201)
|
||||
with_db(readonly: true) do |db|
|
||||
row = db.get_first_row(SELECT_INGESTOR_PROTOCOL_SQL, [MESHCORE_INGESTOR_ID])
|
||||
expect(row["protocol"]).to eq("meshcore")
|
||||
@@ -108,7 +108,7 @@ RSpec.describe "Multi-protocol support" do
|
||||
it "defaults protocol to meshtastic when field is absent" do
|
||||
register_ingestor("!aabbccdd")
|
||||
|
||||
expect(last_response.status).to eq(200)
|
||||
expect(last_response.status).to eq(201)
|
||||
with_db(readonly: true) do |db|
|
||||
row = db.get_first_row(SELECT_INGESTOR_PROTOCOL_SQL, ["!aabbccdd"])
|
||||
expect(row["protocol"]).to eq("meshtastic")
|
||||
@@ -140,7 +140,7 @@ RSpec.describe "Multi-protocol support" do
|
||||
ingestor: MESHCORE_INGESTOR_ID,
|
||||
}
|
||||
post "/api/messages", [msg].to_json, auth_headers
|
||||
expect(last_response.status).to eq(200)
|
||||
expect(last_response.status).to eq(201)
|
||||
|
||||
with_db(readonly: true) do |db|
|
||||
row = db.get_first_row("SELECT protocol FROM messages WHERE id = ?", [42])
|
||||
@@ -159,7 +159,7 @@ RSpec.describe "Multi-protocol support" do
|
||||
ingestor: MESHCORE_INGESTOR_ID,
|
||||
}
|
||||
post "/api/positions", [pos].to_json, auth_headers
|
||||
expect(last_response.status).to eq(200)
|
||||
expect(last_response.status).to eq(201)
|
||||
|
||||
with_db(readonly: true) do |db|
|
||||
row = db.get_first_row("SELECT protocol FROM positions WHERE id = ?", [100])
|
||||
@@ -177,7 +177,7 @@ RSpec.describe "Multi-protocol support" do
|
||||
ingestor: MESHCORE_INGESTOR_ID,
|
||||
}
|
||||
post "/api/telemetry", [tel].to_json, auth_headers
|
||||
expect(last_response.status).to eq(200)
|
||||
expect(last_response.status).to eq(201)
|
||||
|
||||
with_db(readonly: true) do |db|
|
||||
row = db.get_first_row("SELECT protocol FROM telemetry WHERE id = ?", [200])
|
||||
@@ -196,7 +196,7 @@ RSpec.describe "Multi-protocol support" do
|
||||
ingestor: MESHCORE_INGESTOR_ID,
|
||||
}
|
||||
post "/api/traces", [trace].to_json, auth_headers
|
||||
expect(last_response.status).to eq(200)
|
||||
expect(last_response.status).to eq(201)
|
||||
|
||||
with_db(readonly: true) do |db|
|
||||
row = db.get_first_row("SELECT protocol FROM traces WHERE id = ?", [300])
|
||||
@@ -214,7 +214,7 @@ RSpec.describe "Multi-protocol support" do
|
||||
ingestor: MESHCORE_INGESTOR_ID,
|
||||
}
|
||||
post "/api/messages", [msg].to_json, auth_headers
|
||||
expect(last_response.status).to eq(200)
|
||||
expect(last_response.status).to eq(201)
|
||||
|
||||
with_db(readonly: true) do |db|
|
||||
row = db.get_first_row("SELECT long_name FROM nodes WHERE node_id = ?", ["!11223300"])
|
||||
@@ -231,7 +231,7 @@ RSpec.describe "Multi-protocol support" do
|
||||
ingestor: MESHCORE_INGESTOR_ID,
|
||||
}
|
||||
post "/api/messages", [msg].to_json, auth_headers
|
||||
expect(last_response.status).to eq(200)
|
||||
expect(last_response.status).to eq(201)
|
||||
|
||||
# Meshtastic ingestor posts same ID — should be ignored
|
||||
meshtastic_msg = {
|
||||
@@ -241,7 +241,7 @@ RSpec.describe "Multi-protocol support" do
|
||||
text: "meshtastic impostor",
|
||||
}
|
||||
post "/api/messages", [meshtastic_msg].to_json, auth_headers
|
||||
expect(last_response.status).to eq(200)
|
||||
expect(last_response.status).to eq(201)
|
||||
|
||||
with_db(readonly: true) do |db|
|
||||
row = db.get_first_row("SELECT text, protocol FROM messages WHERE id = ?", [500])
|
||||
@@ -270,7 +270,7 @@ RSpec.describe "Multi-protocol support" do
|
||||
text: "meshtastic fallback attempt",
|
||||
}
|
||||
post "/api/messages", [meshtastic_msg].to_json, auth_headers
|
||||
expect(last_response.status).to eq(200)
|
||||
expect(last_response.status).to eq(201)
|
||||
|
||||
with_db(readonly: true) do |db|
|
||||
row = db.get_first_row("SELECT text, protocol FROM messages WHERE id = ?", [501])
|
||||
@@ -295,7 +295,7 @@ RSpec.describe "Multi-protocol support" do
|
||||
"ingestor" => MESHCORE_INGESTOR_ID,
|
||||
}
|
||||
post "/api/nodes", payload.to_json, auth_headers
|
||||
expect(last_response.status).to eq(200)
|
||||
expect(last_response.status).to eq(201)
|
||||
|
||||
with_db(readonly: true) do |db|
|
||||
row = db.get_first_row("SELECT protocol FROM nodes WHERE node_id = ?", [ALT_NODE_ID])
|
||||
@@ -313,7 +313,7 @@ RSpec.describe "Multi-protocol support" do
|
||||
|
||||
payload = { ALT_NODE_ID2 => { "num" => 0xccddee00, "lastHeard" => now - 10 } }
|
||||
post "/api/nodes", payload.to_json, auth_headers
|
||||
expect(last_response.status).to eq(200)
|
||||
expect(last_response.status).to eq(201)
|
||||
|
||||
with_db(readonly: true) do |db|
|
||||
row = db.get_first_row("SELECT protocol FROM nodes WHERE node_id = ?", [ALT_NODE_ID2])
|
||||
@@ -329,7 +329,7 @@ RSpec.describe "Multi-protocol support" do
|
||||
nodes["ingestor"] = MESHCORE_INGESTOR_ID
|
||||
post "/api/nodes", nodes.to_json, auth_headers
|
||||
|
||||
expect(last_response.status).to eq(200)
|
||||
expect(last_response.status).to eq(201)
|
||||
end
|
||||
|
||||
it "does not count the wrapper-level protocol key against the node batch limit" do
|
||||
@@ -342,7 +342,7 @@ RSpec.describe "Multi-protocol support" do
|
||||
nodes["protocol"] = "meshcore"
|
||||
post "/api/nodes", nodes.to_json, auth_headers
|
||||
|
||||
expect(last_response.status).to eq(200)
|
||||
expect(last_response.status).to eq(201)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -506,7 +506,7 @@ RSpec.describe "Multi-protocol support" do
|
||||
text: "legacy message",
|
||||
}
|
||||
post "/api/messages", [msg].to_json, auth_headers
|
||||
expect(last_response.status).to eq(200)
|
||||
expect(last_response.status).to eq(201)
|
||||
|
||||
with_db(readonly: true) do |db|
|
||||
row = db.get_first_row("SELECT protocol FROM messages WHERE id = ?", [999])
|
||||
@@ -544,7 +544,7 @@ RSpec.describe "Multi-protocol support" do
|
||||
protocol: "meshcore",
|
||||
}
|
||||
post "/api/messages", [msg].to_json, auth_headers
|
||||
expect(last_response.status).to eq(200)
|
||||
expect(last_response.status).to eq(201)
|
||||
|
||||
with_db(readonly: true) do |db|
|
||||
message_row = db.get_first_row(
|
||||
@@ -572,7 +572,7 @@ RSpec.describe "Multi-protocol support" do
|
||||
"protocol" => "meshcore",
|
||||
}
|
||||
post "/api/nodes", payload.to_json, auth_headers
|
||||
expect(last_response.status).to eq(200)
|
||||
expect(last_response.status).to eq(201)
|
||||
|
||||
with_db(readonly: true) do |db|
|
||||
row = db.get_first_row("SELECT protocol FROM nodes WHERE node_id = ?", ["!11aabbcc"])
|
||||
@@ -591,7 +591,7 @@ RSpec.describe "Multi-protocol support" do
|
||||
"protocol" => "meshtastic",
|
||||
}
|
||||
post "/api/nodes", payload.to_json, auth_headers
|
||||
expect(last_response.status).to eq(200)
|
||||
expect(last_response.status).to eq(201)
|
||||
|
||||
with_db(readonly: true) do |db|
|
||||
row = db.get_first_row("SELECT protocol FROM nodes WHERE node_id = ?", ["!22aabbcc"])
|
||||
@@ -610,7 +610,7 @@ RSpec.describe "Multi-protocol support" do
|
||||
protocol: "reticulum",
|
||||
}
|
||||
post "/api/messages", [msg].to_json, auth_headers
|
||||
expect(last_response.status).to eq(200)
|
||||
expect(last_response.status).to eq(201)
|
||||
|
||||
with_db(readonly: true) do |db|
|
||||
row = db.get_first_row("SELECT protocol FROM messages WHERE id = ?", [6002])
|
||||
@@ -627,7 +627,7 @@ RSpec.describe "Multi-protocol support" do
|
||||
ingestor: "!unregistered000",
|
||||
}
|
||||
post "/api/messages", [msg].to_json, auth_headers
|
||||
expect(last_response.status).to eq(200)
|
||||
expect(last_response.status).to eq(201)
|
||||
|
||||
with_db(readonly: true) do |db|
|
||||
row = db.get_first_row("SELECT protocol FROM messages WHERE id = ?", [6003])
|
||||
@@ -645,7 +645,7 @@ RSpec.describe "Multi-protocol support" do
|
||||
protocol: " MeshCore ",
|
||||
}
|
||||
post "/api/messages", [msg].to_json, auth_headers
|
||||
expect(last_response.status).to eq(200)
|
||||
expect(last_response.status).to eq(201)
|
||||
|
||||
with_db(readonly: true) do |db|
|
||||
row = db.get_first_row("SELECT protocol FROM messages WHERE id = ?", [6004])
|
||||
|
||||
Reference in New Issue
Block a user