diff --git a/alembic/versions/20260703_2250_57bb65130b97_add_path_hash_bytes_to_raw_packets.py b/alembic/versions/20260703_2250_57bb65130b97_add_path_hash_bytes_to_raw_packets.py new file mode 100644 index 0000000..ca552dd --- /dev/null +++ b/alembic/versions/20260703_2250_57bb65130b97_add_path_hash_bytes_to_raw_packets.py @@ -0,0 +1,117 @@ +"""add path_hash_bytes to raw_packets + +Revision ID: 57bb65130b97 +Revises: 38abdf4651fc +Create Date: 2026-07-03 22:50:33.598167+00:00 + +Adds a nullable ``path_hash_bytes`` column to ``raw_packets`` (widest +path-hash prefix width: 1/2/3, or NULL when no path hashes are present). +The collector computes this at ingest going forward; this migration +backfills historical rows from their stored ``decoded`` JSON using a +self-contained, frozen copy of the dual-path extraction logic. + +""" + +from typing import Any, Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "57bb65130b97" +down_revision: Union[str, None] = "38abdf4651fc" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +_BATCH_SIZE = 1000 + +# Declared with a sa.JSON-typed column so the SQLAlchemy type adapter +# deserializes ``decoded`` to a Python dict consistently on both SQLite +# (TEXT storage) and Postgres. Using raw text("SELECT decoded ...") would +# bypass the adapter and make deserialization driver-dependent. +_raw_packets = sa.table( + "raw_packets", + sa.Column("id", sa.String, primary_key=True), + sa.Column("decoded", sa.JSON), + sa.Column("path_hash_bytes", sa.Integer), +) + + +def _normalize_hash_list(value: Any) -> list[str] | None: + """Frozen copy of LetsMeshNormalizer._normalize_hash_list. + + Accepts even-length hex strings of 2 or more characters. + Each string is uppercased and validated as hexadecimal. + """ + if not isinstance(value, list): + return None + normalized: list[str] = [] + for item in value: + if not isinstance(item, str): + continue + token = item.strip().upper() + if len(token) < 2 or len(token) % 2 != 0: + continue + if any(ch not in "0123456789ABCDEF" for ch in token): + continue + normalized.append(token) + return normalized or None + + +def _compute_path_hash_byte_width(decoded: Any) -> int | None: + """Frozen copy of the dual-path extraction + max(len//2) logic. + + Path hashes live at ``decoded.path`` for normal packets, with + ``decoded.payload.decoded.pathHashes`` as the trace-style fallback. + Returns the widest prefix width in bytes, or None when no path hashes + are present/decodable. + """ + if not isinstance(decoded, dict): + return None + hashes = _normalize_hash_list(decoded.get("path")) + if not hashes: + payload = decoded.get("payload") or {} + inner = payload.get("decoded") or {} + hashes = _normalize_hash_list(inner.get("pathHashes")) + if not hashes: + return None + return max(len(h) // 2 for h in hashes) + + +def upgrade() -> None: + with op.batch_alter_table("raw_packets", schema=None) as batch_op: + batch_op.add_column(sa.Column("path_hash_bytes", sa.Integer(), nullable=True)) + + conn = op.get_bind() + + # Keyset-paginated backfill over all rows so rows whose ``decoded`` is + # NULL (uncomputable) are visited once and left NULL without causing an + # infinite loop. + last_id: str | None = None + while True: + query = ( + sa.select(_raw_packets.c.id, _raw_packets.c.decoded) + .order_by(_raw_packets.c.id) + .limit(_BATCH_SIZE) + ) + if last_id is not None: + query = query.where(_raw_packets.c.id > last_id) + + batch = conn.execute(query).all() + if not batch: + break + + for row in batch: + last_id = row.id + width = _compute_path_hash_byte_width(row.decoded) + if width is not None: + conn.execute( + sa.update(_raw_packets) + .where(_raw_packets.c.id == row.id) + .values(path_hash_bytes=width) + ) + + +def downgrade() -> None: + with op.batch_alter_table("raw_packets", schema=None) as batch_op: + batch_op.drop_column("path_hash_bytes") diff --git a/docs/plans/20260703-2338-path-hash-bytes-filter/plan.md b/docs/plans/20260703-2338-path-hash-bytes-filter/plan.md new file mode 100644 index 0000000..fba610b --- /dev/null +++ b/docs/plans/20260703-2338-path-hash-bytes-filter/plan.md @@ -0,0 +1,330 @@ +# Persist & Filter by Path-Hash Byte Width + +## Summary + +The packet list page (`/packets`, served by `GET /api/v1/packet-groups`) shows a +"path-hash byte width" metric per row — the ruler badge (`1B`/`2B`/`3B`/`?B`) we +recently added to the reception widget. That value is currently **derived at +request time** in Python by decoding each group's `decoded` JSON, which makes it +unfilterable at the database level and forces an expensive extra query on every +list load. + +This plan **persists `path_hash_bytes` as a real column on `RawPacket`**, computed +once at ingest by the collector and backfilled for historical rows. With the value +persisted, the grouped-list route's Python decode loop collapses into a SQL +`MAX()` aggregate, and we add a clean **discrete-select filter** (Any / 1B / 2B / +3B) to the packet list page via a `HAVING` clause — fully consistent pagination and +counts, with no per-request JSON decoding. + +## Background & Motivation + +### Current State + +- `path_hash_bytes` is **not a column**. It is computed at runtime in + `api/routes/packet_groups.py:198-216` ("Phase 3"), which fetches the `decoded` + JSON for every reception of the current page's packet hashes, extracts the path + arrays, and aggregates the widest byte-width (1/2/3 from 2/4/6 hex chars). The + aggregation was recently fixed (PR #292, commit `f845830`) to take `MAX` + across **all** receptions because the decoded `path` is observer-relative — a + single representative row often misses a multibyte path that other receptions + carry. +- The packet list widget renders this as `[Dish][oc] × [Path][rc] @ [Ruler][pb]`, + the most prominent path metric on the list. +- The collector is the **sole writer** of `RawPacket`, through a single chokepoint + `store_raw_packet` (`collector/handlers/raw_packet.py:122-138`). It already + computes `path_len` (hop count) there with a two-tier fallback + (`raw_packet.py:92-96`), and it already has a collector-side mirror of the + path-hash extraction: `_extract_message_path_hashes` + (`letsmesh_normalizer.py:826-844`) + the `_normalize_hash_list` staticmethod + (`:846-867`). +- The raw `/packets` endpoint already supports `min_path_len`/`max_path_len` + range filters on the real `path_len` column — the closest filtering precedent. + +### Problems + +- **Unfilterable.** Because the value is computed post-query in Python, it cannot + be filtered at the SQL level without either persisting a column or pushing + brittle, backend-specific JSON extraction into SQL (dual-path: `decoded.path` + and the trace fallback `decoded.payload.decoded.pathHashes`). +- **Pagination inconsistency.** A post-query Python filter (the only no-migration + hack) would make `total` and page sizes inconsistent — bad UX. +- **Request-time cost.** Every list load performs an extra decoded-JSON fetch + + per-row Python decode just to render the badge. + +### Why Persist a Column (chosen approach) + +The user confirmed the metric is **byte width** (the ruler badge) and the +approach is **persisted column (A)**. This is the only solution that is correct, +maintainable, and gives consistent pagination. The collector already extracts +path hashes at ingest, so computing the byte-width there is a trivial addition +covering 100% of new rows; a one-time Python backfill covers historical rows. + +## Goals + +- Persist `path_hash_bytes` (nullable `Integer`, values 1/2/3 or NULL) on + `RawPacket`, computed at ingest from the decoded path hashes. +- Backfill the column for all existing `raw_packets` rows from their `decoded` + JSON, portably across SQLite and Postgres. +- Add a **discrete-select filter** (Any / 1B / 2B / 3B) to the `/packets` list + page, applied as a SQL `HAVING` on the per-group `MAX(path_hash_bytes)`. +- Simplify the grouped-list route: replace the Python decode loop (Phase 3) with + a SQL `MAX()` aggregate, removing the per-request JSON fetch. + +## Non-Goals + +- Filtering the **raw** `/packets` endpoint (not the list page) by byte width. + The column will exist on the model and DB, but surfacing it on the raw endpoint + / `RawPacketRead` schema is out of scope (easy follow-up). +- An "Unknown" (`?B` / null) filter option — the user chose **known widths only** + (1/2/3). Null-width packets are simply never selected by the filter; they + remain visible under "Any". +- A numeric range (min/max) or multi-select filter shape — the user chose a + **discrete single-select**. +- Indexing `path_hash_bytes` (mirrors `path_len`, which is also unindexed). Add + an index later only if query plans show a need. +- Changing the per-reception path extraction in the **detail** route + (`_extract_path_hashes` stays; only the list-path width computation is removed). + +## Requirements + +### Functional Requirements + +- **FR-1**: A new nullable `Integer` column `path_hash_bytes` on `raw_packets`, + holding the widest path-hash prefix width (1/2/3) for that reception, or NULL + when no path hashes are present/decodable. +- **FR-2**: Every newly inserted `RawPacket` row has `path_hash_bytes` computed + from its `decoded` dict at ingest (collector is the sole writer). +- **FR-3**: Historical `raw_packets` rows are backfilled from their `decoded` + JSON during migration, using the same dual-path extraction and `max(len//2)` + semantics as the runtime computation. +- **FR-4**: `GET /api/v1/packet-groups` accepts an optional + `path_hash_bytes` query parameter (integer 1/2/3). When set, only groups whose + `MAX(path_hash_bytes)` equals the value are returned. Omitting it returns all + groups (including null-width ones) — unchanged behavior. +- **FR-5**: The list response still nulls `path_hash_bytes` for redacted groups + (channel-visibility), exactly as today (`None if is_redacted else ...`). +- **FR-6**: The `/packets` SPA page offers a **discrete `` + with options Any / 1B / 2B / 3B, mirroring the `event_type` select style. + Option labels reuse `packets.path_width_bytes` (`{{count}}B`) and + `common.all` / a new `packets.filter_path_width` label. + - Add `if (path_hash_bytes !== '') apiParams.path_hash_bytes = path_hash_bytes;` + (~line 111). + - Add `path_hash_bytes` to the `pagination(...)` params (~line 166) and + `headerParams` (~line 194) so it survives pagination and sort. +- **TR-6** — i18n (`locales/en.json`, `locales/nl.json`): add + `packets.filter_path_width` ("Path width" / "Padbreedte"). Option labels reuse + existing `packets.path_width_bytes` and `common.all` — no new keys needed for + them. +- **TR-7** — `apiGet` (`api.js`) and `createFilterHandler` (`components.js`) + already skip empty/falsy values, so no changes there. + +## Implementation Plan + +### Phase 1: Model & Migration + +- Add `path_hash_bytes` column to `RawPacket` (TR-1). +- Generate the migration with `--autogenerate` against head `38abdf4651fc` + (TR-3), producing `batch_alter_table` `add_column`. +- Manually append the Python batched backfill loop (TR-3): read + `id, decoded WHERE path_hash_bytes IS NULL` in batches (~1000 rows/batch) + via a Core `select()` on a `sa.Table` with a `sa.JSON`-typed `decoded` column + (so the type adapter deserializes to a `dict` on both SQLite and Postgres — + do **not** use raw `text("SELECT decoded ...")`), compute width, `UPDATE`. + The backfill must include a **self-contained**, frozen copy of the dual-path + extraction + `max(len//2)` logic — no imports of app code that may change in + future versions. Rows where `decoded` is `None` are left as `NULL` (the + column is nullable; `WHERE path_hash_bytes IS NULL` naturally skips + already-filled rows and leaves truly uncomputable ones alone). +- Verify the model test (column presence/default) and that `db upgrade` applies + cleanly on both backends. + +### Phase 2: Collector Ingest Compute + +- Add the module-level `_path_hash_byte_width(hashes)` helper in + `collector/handlers/raw_packet.py`. +- In `store_raw_packet`, compute `path_hash_bytes` from `decoded_packet` after + `path_len`, using `LetsMeshNormalizer._normalize_hash_list` + the two-tier + lookup (TR-2). Pass it into the `RawPacket(...)` kwargs. +- Add a collector test: `store_raw_packet` persists the expected width for a + decoded packet carrying `decoded.path`, for the trace fallback path, and + `None` when no path is present (mirror the existing `path_len` test). + +### Phase 3: API Route Simplification & Filter + +- In `packet_groups.py`, add the `func.max(RawPacket.path_hash_bytes)` aggregate + to Phase 1 and the new `path_hash_bytes` query param + `HAVING` filter (TR-4). +- Delete Phase 3 (the Python decode loop); read `grp.path_hash_bytes`. Remove the + now-dead `_path_hash_byte_width` helper. Keep `_extract_path_hashes`. +- Preserve redaction nulling in the item construction (TR-4 / FR-5). +- Update `tests/test_api/test_packet_groups.py::TestPathHashBytes` to set + `path_hash_bytes=` directly on inserted `RawPacket` rows (tests bypass the + collector). Keep 1/2/3/None/redacted coverage. +- Add filter tests: `?path_hash_bytes=2` returns only width-2 groups; + `?path_hash_bytes=1`/`3` likewise; omitted returns all; a redacted group's + response field is still null. + +### Phase 4: Frontend Filter & i18n + +- Add the discrete `` to packets.js + - [x] 4.1.1 Parse `const path_hash_bytes = query.path_hash_bytes || '';` (~line 57) + - [x] 4.1.2 Extend `hasActiveFilters` with `|| path_hash_bytes !== ''` (~line 72) + - [x] 4.1.3 Add to `apiParams`: `if (path_hash_bytes !== '') apiParams.path_hash_bytes = path_hash_bytes;` (~line 111) + - [x] 4.1.4 Add `path_hash_bytes` to `pagination(...)` params (~line 166) + - [x] 4.1.5 Add `path_hash_bytes` to `headerParams` (~line 194) + - [x] 4.1.6 Add 4th entry to `filterFields` array (~line 170): + ` + `, + () => html` +
+ +
`, ]; - const headerParams = { search, event_type, channel_idx, limit }; + const headerParams = { search, event_type, channel_idx, path_hash_bytes, limit }; const sortable = (label, sortKey) => sortableTableHeader(label, { sortKey, currentSort: sort, currentOrder: order, navigate, basePath: '/packets', params: headerParams, diff --git a/src/meshcore_hub/web/static/locales/en.json b/src/meshcore_hub/web/static/locales/en.json index e6a4105..b97d5e0 100644 --- a/src/meshcore_hub/web/static/locales/en.json +++ b/src/meshcore_hub/web/static/locales/en.json @@ -82,6 +82,7 @@ "sign_out": "Sign Out", "view_details": "View Details", "all_types": "All Types", + "all": "Any", "all_channels": "All Channels", "all_members": "All Members", "all_operators": "All Operators", @@ -218,6 +219,7 @@ }, "packets": { "filter_event_type": "Event Type", + "filter_path_width": "Path Width", "filter_min_snr": "Min SNR", "filter_max_snr": "Max SNR", "redacted": "Restricted", @@ -248,6 +250,7 @@ "path_nodes_more": "+{{count}} more — view all →", "not_found_retention": "Packet not found — it may have been cleaned up due to data retention.", "path_width_bytes": "{{count}}B", + "path_width_unknown": "?B", "path_width_title": "Path hash width (bytes)", "sort": { "newest": "Time (newest)", diff --git a/src/meshcore_hub/web/static/locales/nl.json b/src/meshcore_hub/web/static/locales/nl.json index e46d29e..469c19e 100644 --- a/src/meshcore_hub/web/static/locales/nl.json +++ b/src/meshcore_hub/web/static/locales/nl.json @@ -80,6 +80,7 @@ "sign_out": "Uitloggen", "view_details": "Details bekijken", "all_types": "Alle types", + "all": "Alle", "node_type": "Knooppunttype", "show": "Toon", "search_placeholder": "Zoek op naam, ID of publieke sleutel...", @@ -160,6 +161,7 @@ }, "packets": { "filter_event_type": "Gebeurtenistype", + "filter_path_width": "Padbreedte", "filter_min_snr": "Min. SNR", "filter_max_snr": "Max. SNR", "redacted": "Beperkt", @@ -184,6 +186,7 @@ "path_nodes_more": "+{{count}} meer — alle bekijken →", "not_found_retention": "Pakket niet gevonden — mogelijk opgeschoond door dataretentie.", "path_width_bytes": "{{count}}B", + "path_width_unknown": "?B", "path_width_title": "Breedte pad-hash (bytes)", "sort": { "newest": "Tijd (nieuwste)", diff --git a/tests/test_api/test_packet_groups.py b/tests/test_api/test_packet_groups.py index 23b0f3e..199d630 100644 --- a/tests/test_api/test_packet_groups.py +++ b/tests/test_api/test_packet_groups.py @@ -300,14 +300,14 @@ class TestListPacketGroups: class TestPathHashBytes: - """Tests for the derived path_hash_bytes field on the list endpoint.""" + """Tests for the persisted path_hash_bytes field on the list endpoint.""" def test_one_byte_path(self, client_no_auth, api_db_session): api_db_session.add( RawPacket( raw_hex="AA", packet_hash="H1", - decoded={"path": ["aa", "bb"]}, + path_hash_bytes=1, received_at=_now(), ) ) @@ -320,7 +320,7 @@ class TestPathHashBytes: RawPacket( raw_hex="AA", packet_hash="H1", - decoded={"path": ["aabb"]}, + path_hash_bytes=2, received_at=_now(), ) ) @@ -328,17 +328,45 @@ class TestPathHashBytes: item = client_no_auth.get("/api/v1/packet-groups").json()["items"][0] assert item["path_hash_bytes"] == 2 - def test_mixed_path_uses_max(self, client_no_auth, api_db_session): + def test_three_byte_path(self, client_no_auth, api_db_session): api_db_session.add( RawPacket( raw_hex="AA", packet_hash="H1", - decoded={"path": ["aa", "aabb"]}, + path_hash_bytes=3, received_at=_now(), ) ) api_db_session.commit() item = client_no_auth.get("/api/v1/packet-groups").json()["items"][0] + assert item["path_hash_bytes"] == 3 + + def test_width_aggregates_across_receptions(self, client_no_auth, api_db_session): + """Width is observer-relative; take the widest across all receptions. + + The oldest reception (representative) carries no path, while a newer + reception of the same packet saw a 2-byte path. The badge must reflect + the widest path seen, not just the representative row. + """ + base = _now() + api_db_session.add( + RawPacket( + raw_hex="AA", + packet_hash="H1", + path_hash_bytes=None, + received_at=base, + ) + ) + api_db_session.add( + RawPacket( + raw_hex="BB", + packet_hash="H1", + path_hash_bytes=2, + received_at=base.replace(microsecond=base.microsecond + 1), + ) + ) + api_db_session.commit() + item = client_no_auth.get("/api/v1/packet-groups").json()["items"][0] assert item["path_hash_bytes"] == 2 def test_no_path_returns_none(self, client_no_auth, api_db_session): @@ -366,7 +394,7 @@ class TestPathHashBytes: raw_hex="SECRET", packet_hash="ADM_HASH", channel_idx=adm_idx, - decoded={"path": ["aabb"]}, + path_hash_bytes=2, received_at=_now(), ) ) @@ -376,6 +404,142 @@ class TestPathHashBytes: assert item["path_hash_bytes"] is None +class TestPathHashBytesFilter: + """Tests for the ?path_hash_bytes= query filter.""" + + def test_filter_returns_only_matching_width(self, client_no_auth, api_db_session): + now = _now() + api_db_session.add_all( + [ + RawPacket( + raw_hex="A", packet_hash="H1", path_hash_bytes=1, received_at=now + ), + RawPacket( + raw_hex="B", packet_hash="H2", path_hash_bytes=2, received_at=now + ), + RawPacket( + raw_hex="C", packet_hash="H3", path_hash_bytes=3, received_at=now + ), + ] + ) + api_db_session.commit() + + data = client_no_auth.get("/api/v1/packet-groups?path_hash_bytes=2").json() + assert data["total"] == 1 + assert data["items"][0]["packet_hash"] == "H2" + + def test_filter_width_1(self, client_no_auth, api_db_session): + now = _now() + api_db_session.add_all( + [ + RawPacket( + raw_hex="A", packet_hash="H1", path_hash_bytes=1, received_at=now + ), + RawPacket( + raw_hex="B", packet_hash="H2", path_hash_bytes=3, received_at=now + ), + ] + ) + api_db_session.commit() + + data = client_no_auth.get("/api/v1/packet-groups?path_hash_bytes=1").json() + assert data["total"] == 1 + assert data["items"][0]["packet_hash"] == "H1" + + def test_filter_width_3(self, client_no_auth, api_db_session): + now = _now() + api_db_session.add_all( + [ + RawPacket( + raw_hex="A", packet_hash="H1", path_hash_bytes=1, received_at=now + ), + RawPacket( + raw_hex="B", packet_hash="H2", path_hash_bytes=3, received_at=now + ), + ] + ) + api_db_session.commit() + + data = client_no_auth.get("/api/v1/packet-groups?path_hash_bytes=3").json() + assert data["total"] == 1 + assert data["items"][0]["packet_hash"] == "H2" + + def test_no_filter_returns_all_including_null(self, client_no_auth, api_db_session): + now = _now() + api_db_session.add_all( + [ + RawPacket( + raw_hex="A", packet_hash="H1", path_hash_bytes=1, received_at=now + ), + RawPacket( + raw_hex="B", packet_hash="H2", path_hash_bytes=None, received_at=now + ), + ] + ) + api_db_session.commit() + + data = client_no_auth.get("/api/v1/packet-groups").json() + assert data["total"] == 2 + widths = {i["path_hash_bytes"] for i in data["items"]} + assert widths == {1, None} + + def test_filter_excludes_null_width(self, client_no_auth, api_db_session): + now = _now() + api_db_session.add_all( + [ + RawPacket( + raw_hex="A", packet_hash="H1", path_hash_bytes=None, received_at=now + ), + RawPacket( + raw_hex="B", packet_hash="H2", path_hash_bytes=2, received_at=now + ), + ] + ) + api_db_session.commit() + + data = client_no_auth.get("/api/v1/packet-groups?path_hash_bytes=2").json() + assert data["total"] == 1 + assert data["items"][0]["path_hash_bytes"] == 2 + + def test_filter_redacted_group_still_null(self, client_no_auth, api_db_session): + adm_key = "FFEEDDCCBBAA99887766554433221100" + adm_idx = int(Channel.compute_channel_hash(adm_key), 16) + now = _now() + api_db_session.add( + Channel( + name="Adm", + key_hex=adm_key, + channel_hash=Channel.compute_channel_hash(adm_key), + visibility="admin", + enabled=True, + ) + ) + api_db_session.add_all( + [ + RawPacket( + raw_hex="SECRET", + packet_hash="ADM", + channel_idx=adm_idx, + path_hash_bytes=2, + received_at=now, + ), + RawPacket( + raw_hex="OK", + packet_hash="PUB", + path_hash_bytes=2, + received_at=now, + ), + ] + ) + api_db_session.commit() + + data = client_no_auth.get("/api/v1/packet-groups?path_hash_bytes=2").json() + assert data["total"] == 2 + adm = next(i for i in data["items"] if i["packet_hash"] == "ADM") + assert adm["redacted"] is True + assert adm["path_hash_bytes"] is None + + class TestGetPacketGroup: """Tests for GET /packet-groups/{hash} (detail).""" diff --git a/tests/test_collector/test_handlers/test_raw_packet.py b/tests/test_collector/test_handlers/test_raw_packet.py index 58bb69d..d77b1e7 100644 --- a/tests/test_collector/test_handlers/test_raw_packet.py +++ b/tests/test_collector/test_handlers/test_raw_packet.py @@ -144,3 +144,84 @@ class TestStoreRawPacketEdges: rp = db_session.execute(select(RawPacket)).scalar_one() assert rp.source_pubkey_prefix is None + + +class TestStoreRawPacketPathHashBytes: + """Tests for path_hash_bytes computation at ingest.""" + + def test_top_level_path_max_byte_width(self, db_manager, db_session): + """Mixed-width hashes in decoded.path persist the widest byte width.""" + decoded = { + "payloadType": 1, + "path": ["aa", "aabbcc"], + "pathLength": 2, + "payload": {"decoded": {"sourceHash": "AABBCCDD"}}, + } + store_raw_packet( + "a" * 64, {"raw": "00", "hash": "h1"}, decoded, "flood", db_manager + ) + + rp = db_session.execute(select(RawPacket)).scalar_one() + assert rp.path_hash_bytes == 3 + + def test_trace_fallback_path_hashes(self, db_manager, db_session): + """Trace-style pathHashes in payload.decoded are used as fallback.""" + decoded = { + "payloadType": 1, + "payload": {"decoded": {"pathHashes": ["aabb", "ccdd"]}}, + } + store_raw_packet( + "a" * 64, {"raw": "00", "hash": "h1"}, decoded, "trace", db_manager + ) + + rp = db_session.execute(select(RawPacket)).scalar_one() + assert rp.path_hash_bytes == 2 + + def test_one_byte_path(self, db_manager, db_session): + """Single-byte hashes persist width 1.""" + decoded = { + "payloadType": 1, + "path": ["aa", "bb"], + "payload": {"decoded": {}}, + } + store_raw_packet( + "a" * 64, {"raw": "00", "hash": "h1"}, decoded, "flood", db_manager + ) + + rp = db_session.execute(select(RawPacket)).scalar_one() + assert rp.path_hash_bytes == 1 + + def test_no_path_returns_none(self, db_manager, db_session): + """No path hashes at all persist path_hash_bytes = None.""" + decoded = {"payloadType": 3, "payload": {"decoded": {}}} + store_raw_packet( + "a" * 64, {"raw": "00", "hash": "h1"}, decoded, "ack", db_manager + ) + + rp = db_session.execute(select(RawPacket)).scalar_one() + assert rp.path_hash_bytes is None + + def test_null_decoded_returns_none(self, db_manager, db_session): + """A None decoded_packet leaves path_hash_bytes as None.""" + store_raw_packet( + "a" * 64, {"raw": "00", "hash": "h1"}, None, "unknown", db_manager + ) + + rp = db_session.execute(select(RawPacket)).scalar_one() + assert rp.path_hash_bytes is None + + def test_path_len_still_persisted(self, db_manager, db_session): + """path_len is still persisted alongside path_hash_bytes.""" + decoded = { + "payloadType": 1, + "path": ["aa", "bb", "cc"], + "pathLength": 3, + "payload": {"decoded": {"sourceHash": "AABBCCDD"}}, + } + store_raw_packet( + "a" * 64, {"raw": "00", "hash": "h1"}, decoded, "flood", db_manager + ) + + rp = db_session.execute(select(RawPacket)).scalar_one() + assert rp.path_len == 3 + assert rp.path_hash_bytes == 1 diff --git a/tests/test_common/test_models.py b/tests/test_common/test_models.py index 8b19dc3..382fa21 100644 --- a/tests/test_common/test_models.py +++ b/tests/test_common/test_models.py @@ -109,6 +109,7 @@ class TestRawPacketModel: assert packet.raw_hex is None assert packet.channel_idx is None assert packet.source_pubkey_prefix is None + assert packet.path_hash_bytes is None assert packet.decoded is None def test_raw_packet_indexes(self) -> None: