diff --git a/docs/plans/20260505-1555-sort-nodes-alpha/plan.md b/docs/plans/20260505-1555-sort-nodes-alpha/plan.md new file mode 100644 index 0000000..35ffc5e --- /dev/null +++ b/docs/plans/20260505-1555-sort-nodes-alpha/plan.md @@ -0,0 +1,484 @@ +# Plan: Column Sort Controls on List Pages + Alpha Sort Default for Nodes + +**Date:** 2026-05-05 +**Branch:** `feat/list-sort-controls` + +--- + +## Background + +The Nodes, Advertisements, and Messages list pages each render a table with static column headers. Sorting is entirely server-side and hardcoded: + +| Page | Current Sort | +|------|-------------| +| Nodes | `last_seen DESC` | +| Advertisements | `received_at DESC` | +| Messages | `received_at DESC` | + +Two requests: +1. **Change Nodes default sort** to alpha by display name (name tag → node.name → public_key fallback) so observers/companions don't constantly sit at the top. +2. **Add clickable column headers** on all three list pages so users can sort by any column. Sort state should be encoded in the URL query string, which naturally survives auto-refresh (since auto-refresh re-reads `params.query` on each tick). + +--- + +## Feature 1: Server-Side Sort Parameters (API) + +Add `sort` and `order` query parameters to all three list endpoints. The `sort` parameter accepts a set of column names per endpoint; invalid values are ignored (default sort is applied). The `order` parameter accepts `asc` or `desc`. + +### 1A. `GET /api/v1/nodes` — `src/meshcore_hub/api/routes/nodes.py` + +**New query parameters:** + +| Parameter | Type | Default | Valid Values | +|-----------|------|---------|--------------| +| `sort` | `str` | `name` | `name`, `public_key`, `last_seen` | +| `order` | `str` | `asc` (when `sort=name`) / `desc` (when `sort=public_key` or `last_seen`) | `asc`, `desc` | + +**Sort column mapping:** + +| `sort` value | ORDER BY expression | Index required? | +|-------------|-------------------|----------------| +| `name` (new default) | `COALESCE(name_tag_value, Node.name, Node.public_key)` | Functional index recommended | +| `public_key` | `Node.public_key` | Already indexed | +| `last_seen` | `Node.last_seen` | Already indexed (`ix_nodes_last_seen`) | + +**Name sort implementation:** + +The display name resolution used in the frontend (`tagName || node.name || truncated public_key`) must be mirrored in SQL. Use a correlated scalar subquery to fetch the `name` tag value: + +```python +from sqlalchemy import func + +name_tag_subq = ( + select(NodeTag.value) + .where(NodeTag.node_id == Node.id, NodeTag.key == "name") + .correlate(Node) + .scalar_subquery() +) + +if sort == "name": + sort_col = func.coalesce(name_tag_subq, Node.name, Node.public_key) +elif sort == "public_key": + sort_col = Node.public_key +elif sort == "last_seen": + sort_col = Node.last_seen +else: + sort_col = func.coalesce(name_tag_subq, Node.name, Node.public_key) + +if order == "desc": + query = query.order_by(sort_col.desc()) +else: + query = query.order_by(sort_col.asc()) +``` + +**Edge case:** `Node.last_seen` can be `NULL`. When `order=asc` and sorting by `last_seen`, `NULL` values sort first in SQLite (which is fine — unseen nodes naturally sort to top). When `order=desc`, `NULL` values sort last (also fine). No special treatment needed. + +**Default change:** The default when no `sort` param is provided changes from `last_seen DESC` to alpha-by-name ascending. + +**Functional index (optional optimization):** + +An expression index on `COALESCE((SELECT value FROM node_tags WHERE node_id = nodes.id AND key = 'name'), name, public_key)` would speed up name sorts. This can be deferred — SQLite handles ORDER BY reasonably without indexes for typical dataset sizes. If added later, it would go in an Alembic migration. + +### 1B. `GET /api/v1/advertisements` — `src/meshcore_hub/api/routes/advertisements.py` + +**New query parameters:** + +| Parameter | Type | Default | Valid Values | +|-----------|------|---------|--------------| +| `sort` | `str` | `time` | `time`, `node_name`, `public_key` | +| `order` | `str` | `desc` | `asc`, `desc` | + +**Sort column mapping:** + +| `sort` value | ORDER BY expression | +|-------------|-------------------| +| `time` (default) | `Advertisement.received_at` | +| `node_name` | `COALESCE(name_tag_subq, SourceNode.name, Advertisement.public_key)` | +| `public_key` | `Advertisement.public_key` | + +**Node name sort implementation:** + +The ads route already joins `SourceNode` (aliased `Node`) via `outerjoin(SourceNode, Advertisement.node_id == SourceNode.id)`. To sort by display name, add a correlated scalar subquery for the name tag — same pattern as the nodes endpoint: + +```python +from sqlalchemy import func + +# SourceNodeNameTag is an alias for the tag lookup +SourceNodeNameTag = aliased(NodeTag) + +name_tag_subq = ( + select(SourceNodeNameTag.value) + .where( + SourceNodeNameTag.node_id == SourceNode.id, + SourceNodeNameTag.key == "name", + ) + .correlate(SourceNode) + .scalar_subquery() +) + +if sort == "node_name": + sort_col = func.coalesce(name_tag_subq, SourceNode.name, Advertisement.public_key) +elif sort == "public_key": + sort_col = Advertisement.public_key +elif sort == "time": + sort_col = Advertisement.received_at +else: + sort_col = Advertisement.received_at +``` + +The `SourceNode` alias is already defined in the route. The `NodeTag` model is already imported. Only the `aliased(NodeTag)` and the subquery are new. + +The "Observers" column is not sortable (it is a count from a separate table) and will render as a plain `` header. + +### 1C. `GET /api/v1/messages` — `src/meshcore_hub/api/routes/messages.py` + +**New query parameters:** + +| Parameter | Type | Default | Valid Values | +|-----------|------|---------|--------------| +| `sort` | `str` | `time` | `time`, `type`, `from`, `message` | +| `order` | `str` | `desc` | `asc`, `desc` | + +**Sort column mapping:** + +| `sort` value | ORDER BY expression | +|-------------|-------------------| +| `time` (default) | `Message.received_at` | +| `type` | `Message.message_type` | +| `from` | `Message.pubkey_prefix` | +| `message` | `Message.text` | + +The "Observers" column is not sortable. The "From" column sorts by `pubkey_prefix` (raw value) rather than resolved display name, since sender name resolution happens in a post-query step. + +**Note on `message` sort:** Sorting a large text column alphabetically is valid SQL but may have limited UX value. Still included for consistency since the column is displayed. + +### Implementation pattern (shared across all three endpoints) + +A helper pattern handles validation and defaults: + +```python +VALID_SORT_COLUMNS: dict[str, Any] = { ... } # column name → ORM attribute or expression +DEFAULT_SORT = "name" # or "time" for ads/messages + +sort = sort if sort in VALID_SORT_COLUMNS else DEFAULT_SORT +order = order if order in ("asc", "desc") else "asc" if sort in ("name", "node_name") else "desc" +``` + +--- + +## Feature 2: Clickable Sort Headers (Frontend) + +### 2A. Shared sort header component — `src/meshcore_hub/web/static/js/spa/components.js` + +Add a `sortableTableHeader()` function that renders a `` with an `` link. Clicking cycles through: none → asc → desc → none. + +**Signature:** + +```js +export function sortableTableHeader(label, { sortKey, currentSort, currentOrder, navigate, basePath, params }) +``` + +**Parameters:** +- `label`: already-translated label string (e.g., `t('entities.node')`) +- `sortKey`: the column name sent to the API +- `currentSort`: currently active sort column (from URL params) +- `currentOrder`: currently active sort direction (`asc` or `desc`) +- `navigate`: SPA router navigate function +- `basePath`: e.g., `'/nodes'` +- `params`: all current query params (to preserve filters) + +**Behavior:** + +| Current state | Next state | Indicator | +|--------------|-----------|-----------| +| Not sorted by this column | `sort=key&order=asc` | (none shown before click) | +| Sorted `asc` by this column | `sort=key&order=desc` | ▴ | +| Sorted `desc` by this column | (remove sort params) | ▾ | + +**URL construction:** + +```js +function buildSortUrl(basePath, params, nextSort, nextOrder) { + const sp = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value !== null && value !== undefined && value !== '') { + sp.set(key, value); + } + } + if (nextSort && nextOrder) { + sp.set('sort', nextSort); + sp.set('order', nextOrder); + } else { + sp.delete('sort'); + sp.delete('order'); + } + const qs = sp.toString(); + return qs ? `${basePath}?${qs}` : basePath; +} +``` + +The `params` object contains filter params only (no `page` — pagination is implicitly reset to page 1 on sort change). When the third click removes sort params, the function returns to the page default via the frontend default logic (see Feature 4). + +**Rendering:** + +```js +export function sortableTableHeader(label, { sortKey, currentSort, currentOrder, navigate, basePath, params }) { + let indicator = ''; + let nextSort, nextOrder; + + if (currentSort !== sortKey) { + nextSort = sortKey; + nextOrder = 'asc'; + } else if (currentOrder === 'asc') { + nextSort = sortKey; + nextOrder = 'desc'; + indicator = ' ▴'; + } else { + nextSort = null; + nextOrder = null; + indicator = ' ▾'; + } + + const url = buildSortUrl(basePath, params, nextSort, nextOrder); + + return html` + { e.preventDefault(); navigate(url); }}> + ${label}${indicator} + + `; +} +``` + +**Not sortable:** Columns without a sort key render as plain `` elements (unchanged from current behavior). + +### 2B. Nodes page — `src/meshcore_hub/web/static/js/spa/pages/nodes.js` + +**Changes:** +1. Parse `sort` and `order` from `params.query`; apply frontend defaults if missing: + +```js +const sort = query.sort || 'name'; +const order = query.order || 'asc'; +``` + +2. Pass `sort`/`order` to `apiGet('/api/v1/nodes', { ..., sort, order })` +3. Replace the static `` elements in the table header with `sortableTableHeader()` calls: + +```js +const headerParams = { search, adv_type, adopted_by, limit }; +const sortable = (label, sortKey) => sortableTableHeader(label, { + sortKey, currentSort: sort, currentOrder: order, + navigate, basePath: '/nodes', params: headerParams, +}); + +// In table header: + + ${sortable(t('entities.node'), 'name')} + ${sortable(t('common.public_key'), 'public_key')} + ${sortable(t('common.last_seen'), 'last_seen')} + +``` + +**Why set defaults in the frontend?** The server applies its own defaults when `sort`/`order` are omitted, but the frontend needs to know the effective sort state to render the correct indicator on the active column. By setting `sort='name'` and `order='asc'` when the URL has no sort params, the "Node" header shows the ▴ indicator on first load and the URL state is always explicit. + +### 2C. Advertisements page — `src/meshcore_hub/web/static/js/spa/pages/advertisements.js` + +**Changes:** +1. Parse `sort` and `order` from `params.query`; apply frontend defaults if missing: + +```js +const sort = query.sort || 'time'; +const order = query.order || 'desc'; +``` + +2. Pass `sort`/`order` to `apiGet('/api/v1/advertisements', { ..., sort, order })` +3. Make the "Node" header sortable by `node_name`, the "Public Key" header sortable by `public_key`, and the "Time" header sortable by `time`: + +```js +const headerParams = { search, observed_by, adopted_by, limit }; +const sortable = (label, sortKey) => sortableTableHeader(label, { + sortKey, currentSort: sort, currentOrder: order, + navigate, basePath: '/advertisements', params: headerParams, +}); + +// In table header: + + ${sortable(t('entities.node'), 'node_name')} + ${sortable(t('common.public_key'), 'public_key')} + ${sortable(t('common.time'), 'time')} + ${t('common.observers')} + +``` + +Sorting the "Node" column uses the same COALESCE display name logic as the nodes endpoint (name tag → SourceNode.name → public_key fallback), so the sort order matches the displayed names. + +### 2D. Messages page — `src/meshcore_hub/web/static/js/spa/pages/messages.js` + +**Changes:** +1. Parse `sort` and `order` from `params.query`; apply frontend defaults if missing: + +```js +const sort = query.sort || 'time'; +const order = query.order || 'desc'; +``` + +2. Pass `sort`/`order` to `apiGet('/api/v1/messages', { ..., sort, order })` +3. Make Type, Time, From, and Message headers sortable; Observers stays plain: + +```js +const headerParams = { message_type, channel_idx, observed_by, limit }; +const sortable = (label, sortKey) => sortableTableHeader(label, { + sortKey, currentSort: sort, currentOrder: order, + navigate, basePath: '/messages', params: headerParams, +}); + +// In table header: + + ${sortable(t('common.type'), 'type')} + ${sortable(t('common.time'), 'time')} + ${sortable(t('common.from'), 'from')} + ${sortable(t('entities.message'), 'message')} + ${t('common.observers')} + +``` + +--- + +## Feature 3: Sort State Persistence + +### How it works (no code changes needed beyond what's above) + +1. Clicking a column header calls `router.navigate(url)`, which pushes a new history entry and triggers a page re-render. +2. The page component reads `sort`/`order` from `params.query` and passes them to the API. +3. The auto-refresh timer calls `fetchAndRenderData()`, which reads the same `params.query` — so the sort params are included in each refresh tick. +4. The `pagination()` component already preserves all non-empty params (it skips `page` explicitly), so `sort`/`order` flow through pagination links automatically. + +### Edge case: What happens to sort when filters change? + +Filter form submits use `createFilterHandler`, which navigates to a URL with only the filter params (no sort/order). This means changing a filter **loses** the current sort state. This is acceptable UX: +- Changing a filter implies a new query intent; resetting sort is reasonable. +- Users can re-apply sort after filtering. + +If this becomes a pain point later, the filter form could be updated to preserve `sort`/`order` params. + +--- + +## Feature 4: Frontend Default Sort (Explicit State) + +To ensure sort indicators are always correct on first load, each page sets explicit `sort`/`order` values when the URL has none: + +| Page | Frontend default | +|------|-----------------| +| Nodes | `sort=name, order=asc` | +| Advertisements | `sort=time, order=desc` | +| Messages | `sort=time, order=desc` | + +```js +// Nodes page +const sort = query.sort || 'name'; +const order = query.order || 'asc'; + +// Advertisements / Messages pages +const sort = query.sort || 'time'; +const order = query.order || 'desc'; +``` + +These defaults are passed to both the API call and the `sortableTableHeader()` component. This ensures: +- The active column header shows the correct indicator (▴ or ▾) on first load. +- The URL state is always explicit — no ambiguous "implicit default" state. +- Clicking the default-sorted column's header cycles to the next state (desc), rather than being a no-op. + +--- + +## i18n Changes + +### `src/meshcore_hub/web/static/locales/en.json` + +No new translation keys are strictly needed — the column headers already have translations (`entities.node`, `common.public_key`, etc.). Sort indicators use Unicode arrows (▴ / ▾), which are language-neutral. + +**Optional:** Add `aria-sort` attribute values for screen readers. These would be English-only static strings embedded in the component (e.g., `"ascending"`, `"descending"`) since they are accessibility metadata, not user-visible labels. Not required for initial implementation. + +--- + +## Test Plan + +### API Tests (`tests/test_api/`) + +**test_nodes.py** — Add test class `TestNodeSort`: + +| Test | Description | +|------|-------------| +| `test_sort_by_name_default` | Default (no `sort` param) returns nodes alpha by display name | +| `test_sort_by_name_asc` | `sort=name&order=asc` | +| `test_sort_by_name_desc` | `sort=name&order=desc` — Z-to-A | +| `test_sort_by_public_key` | `sort=public_key` | +| `test_sort_by_last_seen` | `sort=last_seen&order=asc` | +| `test_sort_name_tag_priority` | Name tag value takes priority over `node.name` in sort order | +| `test_sort_invalid_ignored` | Invalid `sort` value falls back to default | +| `test_sort_nodes_with_null_name` | Nodes with `name=NULL` sort by public_key via COALESCE fallback | + +**test_advertisements.py** — Add test class `TestAdvertisementSort`: + +| Test | Description | +|------|-------------| +| `test_sort_by_time_default` | Default sorts by `received_at DESC` | +| `test_sort_by_time_asc` | `sort=time&order=asc` | +| `test_sort_by_node_name` | `sort=node_name&order=asc` — sorts by display name (COALESCE of name tag → SourceNode.name → public_key) | +| `test_sort_by_node_name_tag_priority` | Name tag value takes priority over SourceNode.name in sort order | +| `test_sort_by_public_key` | `sort=public_key&order=asc` | +| `test_sort_invalid_ignored` | Invalid `sort` value falls back to default | + +**test_messages.py** — Add test class `TestMessageSort`: + +| Test | Description | +|------|-------------| +| `test_sort_by_time_default` | Default sorts by `received_at DESC` | +| `test_sort_by_type` | `sort=type&order=asc` | +| `test_sort_by_from` | `sort=from&order=asc` (sorts by `pubkey_prefix`) | +| `test_sort_by_message` | `sort=message&order=asc` (sorts by `text`) | +| `test_sort_invalid_ignored` | Invalid `sort` value falls back to default | + +### Frontend Tests (`tests/test_web/`) + +No existing frontend JS test infrastructure. Visual verification: +1. Load Nodes page — verify default sort is alpha by name +2. Click "Last Seen" header — verify sort switches to newest-first, indicator shows ▾ +3. Click "Last Seen" again — verify indicator switches to ▴ (oldest-first) +4. Click "Last Seen" a third time — verify sort returns to default (name alpha) +5. Enable auto-refresh — verify sort state persists across refresh ticks +6. Repeat steps 2-5 on Advertisements and Messages pages +7. Verify pagination preserves sort params in page links +8. Verify the "Observers" column header on ads/messages is not clickable + +--- + +## Files Changed + +| File | Change | +|------|--------| +| `src/meshcore_hub/api/routes/nodes.py` | Add `sort`/`order` params, name-sort via COALESCE subquery, change default sort | +| `src/meshcore_hub/api/routes/advertisements.py` | Add `sort`/`order` params (time, node_name, public_key) with COALESCE name-sort | +| `src/meshcore_hub/api/routes/messages.py` | Add `sort`/`order` params (time, type, from, message) | +| `src/meshcore_hub/web/static/js/spa/components.js` | Add `sortableTableHeader()` export | +| `src/meshcore_hub/web/static/js/spa/pages/nodes.js` | Parse sort/order, pass to API, replace `` with sortable headers | +| `src/meshcore_hub/web/static/js/spa/pages/advertisements.js` | Parse sort/order, pass to API, replace `` with sortable headers | +| `src/meshcore_hub/web/static/js/spa/pages/messages.js` | Parse sort/order, pass to API, replace `` with sortable headers | +| `tests/test_api/test_nodes.py` | Add `TestNodeSort` class | +| `tests/test_api/test_advertisements.py` | Add `TestAdvertisementSort` class | +| `tests/test_api/test_messages.py` | Add `TestMessageSort` class | + +--- + +## Sequence + +1. Add `sortableTableHeader()` to `components.js` +2. Add `sort`/`order` params to `nodes.py` with COALESCE name-sort (changing default) +3. Update `nodes.js` with sortable headers and sort/order param passing +4. Add `sort`/`order` params to `advertisements.py` +5. Update `advertisements.js` with sortable headers +6. Add `sort`/`order` params to `messages.py` +7. Update `messages.js` with sortable headers +8. Write API tests for all three endpoints +9. Run `pytest tests/test_api/` to verify +10. Visual verification of all three pages diff --git a/docs/plans/20260505-1555-sort-nodes-alpha/tasks.md b/docs/plans/20260505-1555-sort-nodes-alpha/tasks.md new file mode 100644 index 0000000..a94dd6f --- /dev/null +++ b/docs/plans/20260505-1555-sort-nodes-alpha/tasks.md @@ -0,0 +1,108 @@ +# Tasks: Column Sort Controls on List Pages + Alpha Sort Default for Nodes + +**Plan:** [plan.md](plan.md) +**Branch:** `feat/list-sort-controls` + +--- + +## Phase 1: Shared Frontend Component + +- [ ] **1.1** Add `buildSortUrl()` helper in `src/meshcore_hub/web/static/js/spa/components.js` + - Accepts `(basePath, params, nextSort, nextOrder)` and returns a URL string + - Uses `URLSearchParams` to build query string from `params`, adds/removes `sort`/`order` + +- [ ] **1.2** Add `sortableTableHeader()` export in `src/meshcore_hub/web/static/js/spa/components.js` + - Accepts `(label, { sortKey, currentSort, currentOrder, navigate, basePath, params })` + - Renders `` with cycling sort indicator (▴ / ▾) using `buildSortUrl()` + - Cycle: none → asc → desc → none (removes sort/order on third click) + +## Phase 2: Nodes — Backend + Frontend + +- [ ] **2.1** Add `sort`/`order` query params to `src/meshcore_hub/api/routes/nodes.py` + - Add `sort: Optional[str] = Query(default=None)` param + - Add `order: Optional[str] = Query(default=None)` param + - Define `VALID_SORT_COLUMNS` dict: `name`, `public_key`, `last_seen` + - Build correlated subquery: `COALESCE(name_tag_subq, Node.name, Node.public_key)` for `name` sort + - Change default sort from `last_seen DESC` to alpha-by-name ascending (when no params provided) + - Invalid `sort`/`order` falls back to defaults + +- [ ] **2.2** Update `src/meshcore_hub/web/static/js/spa/pages/nodes.js` + - Parse `sort` and `order` from `query`, applying frontend defaults: `sort = query.sort || 'name'`, `order = query.order || 'asc'` + - Pass `sort`/`order` to `apiGet('/api/v1/nodes', { ..., sort, order })` + - Build `headerParams` from current filter params (search, adv_type, adopted_by, limit) + - Replace static `` elements with `sortableTableHeader()` calls for Node, Public Key, Last Seen columns + +## Phase 3: Advertisements — Backend + Frontend + +- [ ] **3.1** Add `sort`/`order` query params to `src/meshcore_hub/api/routes/advertisements.py` + - Add `sort: Optional[str] = Query(default=None)` param + - Add `order: Optional[str] = Query(default=None)` param + - Define `VALID_SORT_COLUMNS` dict: `time`, `node_name`, `public_key` + - Import `aliased` from `sqlalchemy.orm` (`NodeTag` already imported) + - Build correlated subquery via `aliased(NodeTag)` for `node_name` sort: `COALESCE(name_tag_subq, SourceNode.name, Advertisement.public_key)` + - Invalid `sort`/`order` falls back to `received_at DESC` + +- [ ] **3.2** Update `src/meshcore_hub/web/static/js/spa/pages/advertisements.js` + - Parse `sort` and `order` from `query`, applying frontend defaults: `sort = query.sort || 'time'`, `order = query.order || 'desc'` + - Pass `sort`/`order` to `apiGet('/api/v1/advertisements', { ..., sort, order })` + - Build `headerParams` from current filter params (search, observed_by, adopted_by, limit) + - Replace static `` elements with `sortableTableHeader()` calls for Node (`node_name`), Public Key, Time columns + - Keep Observers column as plain `` + +## Phase 4: Messages — Backend + Frontend + +- [ ] **4.1** Add `sort`/`order` query params to `src/meshcore_hub/api/routes/messages.py` + - Add `sort: Optional[str] = Query(default=None)` param + - Add `order: Optional[str] = Query(default=None)` param + - Define `VALID_SORT_COLUMNS` dict: `time`, `type`, `from`, `message` + - Invalid `sort`/`order` falls back to `received_at DESC` + +- [ ] **4.2** Update `src/meshcore_hub/web/static/js/spa/pages/messages.js` + - Parse `sort` and `order` from `query`, applying frontend defaults: `sort = query.sort || 'time'`, `order = query.order || 'desc'` + - Pass `sort`/`order` to `apiGet('/api/v1/messages', { ..., sort, order })` + - Build `headerParams` from current filter params (message_type, channel_idx, observed_by, limit) + - Replace static `` elements with `sortableTableHeader()` calls for Type, Time, From, Message columns + - Keep Observers column as plain `` + +## Phase 5: API Tests + +- [ ] **5.1** Add `TestNodeSort` class to `tests/test_api/test_nodes.py` + - `test_sort_by_name_default` — no sort param returns alpha by display name + - `test_sort_by_name_asc` — `sort=name&order=asc` + - `test_sort_by_name_desc` — `sort=name&order=desc` + - `test_sort_by_public_key` — `sort=public_key` + - `test_sort_by_last_seen` — `sort=last_seen&order=asc` + - `test_sort_name_tag_priority` — name tag takes priority over `node.name` + - `test_sort_invalid_ignored` — invalid sort falls back to default + - `test_sort_nodes_with_null_name` — `name=NULL` sorts by public_key + +- [ ] **5.2** Add `TestAdvertisementSort` class to `tests/test_api/test_advertisements.py` + - `test_sort_by_time_default` — default sorts by `received_at DESC` + - `test_sort_by_time_asc` — `sort=time&order=asc` + - `test_sort_by_node_name` — `sort=node_name&order=asc` sorts by display name + - `test_sort_by_node_name_tag_priority` — name tag takes priority over SourceNode.name + - `test_sort_by_public_key` — `sort=public_key&order=asc` + - `test_sort_invalid_ignored` — invalid sort falls back to default + +- [ ] **5.3** Add `TestMessageSort` class to `tests/test_api/test_messages.py` + - `test_sort_by_time_default` — default sorts by `received_at DESC` + - `test_sort_by_type` — `sort=type&order=asc` + - `test_sort_by_from` — `sort=from&order=asc` + - `test_sort_by_message` — `sort=message&order=asc` + - `test_sort_invalid_ignored` — invalid sort falls back to default + +## Verification + +- [ ] Run `pytest tests/test_api/test_nodes.py -v` +- [ ] Run `pytest tests/test_api/test_advertisements.py -v` +- [ ] Run `pytest tests/test_api/test_messages.py -v` +- [ ] Visual verification in browser: + - Nodes: default sort is alpha by name; Node header shows ▴ indicator on first load + - Click "Last Seen" → newest-first with ▾ indicator; click again → oldest-first ▴; click again → reset to default + - Advertisements: default sort is newest-first; sort by Node name; sort by Public Key + - Messages: default sort is newest-first; sort by Type, From, Message + - Observers column header on ads/messages is not clickable + - Auto-refresh preserves sort state across ticks + - Pagination preserves sort params in page links + - Filter change resets sort to default +- [ ] Run `pre-commit run --all-files` diff --git a/src/meshcore_hub/api/routes/advertisements.py b/src/meshcore_hub/api/routes/advertisements.py index f739d0f..c68526b 100644 --- a/src/meshcore_hub/api/routes/advertisements.py +++ b/src/meshcore_hub/api/routes/advertisements.py @@ -18,6 +18,8 @@ from meshcore_hub.common.schemas.messages import ( router = APIRouter() +VALID_AD_SORT_COLUMNS = {"time", "node_name", "public_key"} + def _get_tag_name(node: Optional[Node]) -> Optional[str]: """Extract name tag from a node's tags.""" @@ -54,6 +56,8 @@ async def list_advertisements( ), since: Optional[datetime] = Query(None, description="Start timestamp"), until: Optional[datetime] = Query(None, description="End timestamp"), + sort: Optional[str] = Query(None, description="Sort column"), + order: Optional[str] = Query(None, description="Sort direction: asc or desc"), limit: int = Query(50, ge=1, le=100, description="Page size"), offset: int = Query(0, ge=0, description="Page offset"), ) -> AdvertisementList: @@ -115,8 +119,39 @@ async def list_advertisements( count_query = select(func.count()).select_from(query.subquery()) total = session.execute(count_query).scalar() or 0 + # Resolve sort column and direction + sort = sort if sort in VALID_AD_SORT_COLUMNS else "time" + order = order if order in ("asc", "desc") else "desc" + + SourceNodeNameTag = aliased(NodeTag) + name_tag_subq = ( + select(SourceNodeNameTag.value) + .where( + SourceNodeNameTag.node_id == SourceNode.id, + SourceNodeNameTag.key == "name", + ) + .correlate(SourceNode) + .scalar_subquery() + ) + + if sort == "node_name": + _col = func.coalesce(name_tag_subq, SourceNode.name, Advertisement.public_key) + query = query.order_by(_col.desc() if order == "desc" else _col.asc()) + elif sort == "public_key": + query = query.order_by( + Advertisement.public_key.desc() + if order == "desc" + else Advertisement.public_key.asc() + ) + else: + query = query.order_by( + Advertisement.received_at.desc() + if order == "desc" + else Advertisement.received_at.asc() + ) + # Apply pagination - query = query.order_by(Advertisement.received_at.desc()).offset(offset).limit(limit) + query = query.offset(offset).limit(limit) # Execute results = session.execute(query).all() diff --git a/src/meshcore_hub/api/routes/messages.py b/src/meshcore_hub/api/routes/messages.py index d8b6af4..4ea4caa 100644 --- a/src/meshcore_hub/api/routes/messages.py +++ b/src/meshcore_hub/api/routes/messages.py @@ -15,6 +15,8 @@ from meshcore_hub.common.schemas.messages import MessageList, MessageRead router = APIRouter() +VALID_MSG_SORT_COLUMNS = {"time", "type", "from", "message"} + def _get_tag_name(node: Optional[Node]) -> Optional[str]: """Extract name tag from a node's tags.""" @@ -39,6 +41,8 @@ async def list_messages( since: Optional[datetime] = Query(None, description="Start timestamp"), until: Optional[datetime] = Query(None, description="End timestamp"), search: Optional[str] = Query(None, description="Search in message text"), + sort: Optional[str] = Query(None, description="Sort column"), + order: Optional[str] = Query(None, description="Sort direction: asc or desc"), limit: int = Query(50, ge=1, le=100, description="Page size"), offset: int = Query(0, ge=0, description="Page offset"), ) -> MessageList: @@ -79,8 +83,33 @@ async def list_messages( count_query = select(func.count()).select_from(query.subquery()) total = session.execute(count_query).scalar() or 0 + # Resolve sort column and direction + sort = sort if sort in VALID_MSG_SORT_COLUMNS else "time" + order = order if order in ("asc", "desc") else "desc" + + if sort == "type": + query = query.order_by( + Message.message_type.desc() + if order == "desc" + else Message.message_type.asc() + ) + elif sort == "from": + query = query.order_by( + Message.pubkey_prefix.desc() + if order == "desc" + else Message.pubkey_prefix.asc() + ) + elif sort == "message": + query = query.order_by( + Message.text.desc() if order == "desc" else Message.text.asc() + ) + else: + query = query.order_by( + Message.received_at.desc() if order == "desc" else Message.received_at.asc() + ) + # Apply pagination - query = query.order_by(Message.received_at.desc()).offset(offset).limit(limit) + query = query.offset(offset).limit(limit) # Execute results = session.execute(query).all() diff --git a/src/meshcore_hub/api/routes/nodes.py b/src/meshcore_hub/api/routes/nodes.py index 4bd5b76..05ffc85 100644 --- a/src/meshcore_hub/api/routes/nodes.py +++ b/src/meshcore_hub/api/routes/nodes.py @@ -43,6 +43,9 @@ def _node_to_read(node: Node) -> NodeRead: return node_read +VALID_NODE_SORT_COLUMNS = {"name", "public_key", "last_seen"} + + @router.get("", response_model=NodeList) async def list_nodes( _: RequireRead, @@ -58,6 +61,8 @@ async def list_nodes( observer: Optional[bool] = Query( None, description="Filter to nodes that have observed events" ), + sort: Optional[str] = Query(None, description="Sort column"), + order: Optional[str] = Query(None, description="Sort direction: asc or desc"), limit: int = Query(50, ge=1, le=500, description="Page size"), offset: int = Query(0, ge=0, description="Page offset"), ) -> NodeList: @@ -196,8 +201,34 @@ async def list_nodes( count_query = select(func.count()).select_from(query.subquery()) total = session.execute(count_query).scalar() or 0 - # Apply pagination and ordering - query = query.order_by(Node.last_seen.desc()).offset(offset).limit(limit) + # Resolve sort column and direction + sort = sort if sort in VALID_NODE_SORT_COLUMNS else "name" + order = order if order in ("asc", "desc") else ("asc" if sort == "name" else "desc") + + name_tag_subq = ( + select(NodeTag.value) + .where(NodeTag.node_id == Node.id, NodeTag.key == "name") + .correlate(Node) + .scalar_subquery() + ) + + if sort == "name": + _col = func.coalesce(name_tag_subq, Node.name, Node.public_key) + query = query.order_by(_col.desc() if order == "desc" else _col.asc()) + elif sort == "public_key": + query = query.order_by( + Node.public_key.desc() if order == "desc" else Node.public_key.asc() + ) + elif sort == "last_seen": + query = query.order_by( + Node.last_seen.desc() if order == "desc" else Node.last_seen.asc() + ) + else: + _col = func.coalesce(name_tag_subq, Node.name, Node.public_key) + query = query.order_by(_col.desc() if order == "desc" else _col.asc()) + + # Apply pagination + query = query.offset(offset).limit(limit) # Execute nodes = session.execute(query).scalars().all() diff --git a/src/meshcore_hub/web/static/js/spa/components.js b/src/meshcore_hub/web/static/js/spa/components.js index 1112ee6..39cd1ff 100644 --- a/src/meshcore_hub/web/static/js/spa/components.js +++ b/src/meshcore_hub/web/static/js/spa/components.js @@ -15,6 +15,49 @@ export { html, nothing, unsafeHTML }; export { render as litRender } from 'lit-html'; export { t } from './i18n.js'; +function buildSortUrl(basePath, params, nextSort, nextOrder) { + const sp = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value !== null && value !== undefined && value !== '') { + if (Array.isArray(value)) { + value.forEach(item => sp.append(key, String(item))); + } else { + sp.set(key, String(value)); + } + } + } + if (nextSort && nextOrder) { + sp.set('sort', nextSort); + sp.set('order', nextOrder); + } + const qs = sp.toString(); + return qs ? `${basePath}?${qs}` : basePath; +} + +export function sortableTableHeader(label, { sortKey, currentSort, currentOrder, navigate, basePath, params }) { + let indicator = ''; + let nextOrder; + + if (currentSort !== sortKey) { + nextOrder = 'asc'; + } else if (currentOrder === 'asc') { + nextOrder = 'desc'; + indicator = ' \u25B4'; + } else { + nextOrder = 'asc'; + indicator = ' \u25BE'; + } + + const url = buildSortUrl(basePath, params, sortKey, nextOrder); + + return html` + { e.preventDefault(); e.stopPropagation(); navigate(url); }}> + ${label}${indicator} + + `; +} + /** * Get app config from the embedded window object. * @returns {Object} App configuration diff --git a/src/meshcore_hub/web/static/js/spa/pages/advertisements.js b/src/meshcore_hub/web/static/js/spa/pages/advertisements.js index 1a6028a..043221b 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/advertisements.js +++ b/src/meshcore_hub/web/static/js/spa/pages/advertisements.js @@ -3,7 +3,8 @@ import { html, litRender, nothing, t, getConfig, formatDateTime, formatDateTimeShort, formatRelativeTime, warningBadge, - pagination, renderFilterCard, autoSubmit, submitOnEnter, copyToClipboard, renderNodeDisplay, + pagination, sortableTableHeader, + renderFilterCard, autoSubmit, submitOnEnter, copyToClipboard, renderNodeDisplay, observerIcons, observerDetailRow, toggleObserverDetail, toggleCardObserverDetail } from '../components.js'; import { createAutoRefresh } from '../auto-refresh.js'; @@ -18,6 +19,8 @@ export async function render(container, params, router) { const page = parseInt(query.page, 10) || 1; const limit = parseInt(query.limit, 10) || 20; const offset = (page - 1) * limit; + const sort = query.sort || 'time'; + const order = query.order || 'desc'; const config = getConfig(); const tz = config.timezone || ''; @@ -53,7 +56,7 @@ ${displayContent}`, container); async function fetchAndRenderData() { try { - const apiParams = { limit, offset, search }; + const apiParams = { limit, offset, search, sort, order }; if (observed_by.length > 0) apiParams.observed_by = observed_by; if (adopted_by) apiParams.adopted_by = adopted_by; const fetches = [ @@ -181,7 +184,7 @@ ${displayContent}`, container); }); const paginationBlock = pagination(page, totalPages, '/advertisements', { - search, observed_by, adopted_by, limit, + search, observed_by, adopted_by, limit, sort, order, }); const filterFields = [ @@ -228,6 +231,12 @@ ${displayContent}`, container); defaultOpen: isFilterOpen, }); + const headerParams = { search, observed_by, adopted_by, limit }; + const sortable = (label, sortKey) => sortableTableHeader(label, { + sortKey, currentSort: sort, currentOrder: order, + navigate, basePath: '/advertisements', params: headerParams, + }); + renderPage(html`${filterCard}
@@ -238,9 +247,9 @@ ${displayContent}`, container); - - - + ${sortable(t('entities.node'), 'node_name')} + ${sortable(t('common.public_key'), 'public_key')} + ${sortable(t('common.time'), 'time')} diff --git a/src/meshcore_hub/web/static/js/spa/pages/messages.js b/src/meshcore_hub/web/static/js/spa/pages/messages.js index f026374..1fd3baa 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/messages.js +++ b/src/meshcore_hub/web/static/js/spa/pages/messages.js @@ -4,7 +4,7 @@ import { getConfig, formatDateTime, formatDateTimeShort, formatRelativeTime, getChannelLabelsMap, resolveChannelLabel, truncateKey, warningBadge, - pagination, timezoneIndicator, + pagination, sortableTableHeader, timezoneIndicator, renderFilterCard, autoSubmit, submitOnEnter, observerIcons, observerDetailRow, toggleObserverDetail, toggleCardObserverDetail } from '../components.js'; @@ -20,6 +20,8 @@ export async function render(container, params, router) { const page = parseInt(query.page, 10) || 1; const limit = parseInt(query.limit, 10) || 50; const offset = (page - 1) * limit; + const sort = query.sort || 'time'; + const order = query.order || 'desc'; const config = getConfig(); const channelLabels = getChannelLabelsMap(config); @@ -202,7 +204,7 @@ ${displayContent}`, container); async function fetchAndRenderData() { try { - const apiParams = { limit, offset, message_type, channel_idx }; + const apiParams = { limit, offset, message_type, channel_idx, sort, order }; if (observed_by.length > 0) apiParams.observed_by = observed_by; const [data, nodesData] = await Promise.all([ apiGet('/api/v1/messages', apiParams), @@ -314,7 +316,7 @@ ${displayContent}`, container); }); const paginationBlock = pagination(page, totalPages, '/messages', { - message_type, channel_idx, observed_by, limit, + message_type, channel_idx, observed_by, limit, sort, order, }); const observerFilter = sortedNodes.length > 0 @@ -376,6 +378,12 @@ ${displayContent}`, container); defaultOpen: isFilterOpen, }); + const headerParams = { message_type, channel_idx, observed_by, limit }; + const sortable = (label, sortKey) => sortableTableHeader(label, { + sortKey, currentSort: sort, currentOrder: order, + navigate, basePath: '/messages', params: headerParams, + }); + renderPage(html`${filterCard}
@@ -386,10 +394,10 @@ ${displayContent}`, container);
${t('entities.node')}${t('common.public_key')}${t('common.time')}${t('common.observers')}
- - - - + ${sortable(t('common.type'), 'type')} + ${sortable(t('common.time'), 'time')} + ${sortable(t('common.from'), 'from')} + ${sortable(t('entities.message'), 'message')} diff --git a/src/meshcore_hub/web/static/js/spa/pages/nodes.js b/src/meshcore_hub/web/static/js/spa/pages/nodes.js index 2113485..0c42f3a 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/nodes.js +++ b/src/meshcore_hub/web/static/js/spa/pages/nodes.js @@ -3,7 +3,7 @@ import { html, litRender, nothing, getConfig, formatDateTime, formatDateTimeShort, warningBadge, - pagination, + pagination, sortableTableHeader, renderFilterCard, autoSubmit, submitOnEnter, copyToClipboard, renderNodeDisplay, t } from '../components.js'; import { createAutoRefresh } from '../auto-refresh.js'; @@ -16,6 +16,8 @@ export async function render(container, params, router) { const page = parseInt(query.page, 10) || 1; const limit = parseInt(query.limit, 10) || 20; const offset = (page - 1) * limit; + const sort = query.sort || 'name'; + const order = query.order || 'asc'; const config = getConfig(); const tz = config.timezone || ''; @@ -51,7 +53,7 @@ ${displayContent}`, container); async function fetchAndRenderData() { try { - const apiParams = { limit, offset, search, adv_type }; + const apiParams = { limit, offset, search, adv_type, sort, order }; if (adopted_by) apiParams.adopted_by = adopted_by; const fetches = [apiGet('/api/v1/nodes', apiParams)]; if (config.oidc_enabled) { @@ -119,7 +121,7 @@ ${displayContent}`, container); }); const paginationBlock = pagination(page, totalPages, '/nodes', { - search, adv_type, adopted_by, limit, + search, adv_type, adopted_by, limit, sort, order, }); const filterFields = [ @@ -176,6 +178,12 @@ ${displayContent}`, container); defaultOpen: isFilterOpen, }); + const headerParams = { search, adv_type, adopted_by, limit }; + const sortable = (label, sortKey) => sortableTableHeader(label, { + sortKey, currentSort: sort, currentOrder: order, + navigate, basePath: '/nodes', params: headerParams, + }); + renderPage(html`${filterCard}
@@ -186,9 +194,9 @@ ${displayContent}`, container);
${t('common.type')}${t('common.time')}${t('common.from')}${t('entities.message')}${t('common.observers')}
- - - + ${sortable(t('entities.node'), 'name')} + ${sortable(t('common.public_key'), 'public_key')} + ${sortable(t('common.last_seen'), 'last_seen')} diff --git a/tests/test_api/test_advertisements.py b/tests/test_api/test_advertisements.py index 94f044b..59414e0 100644 --- a/tests/test_api/test_advertisements.py +++ b/tests/test_api/test_advertisements.py @@ -292,3 +292,190 @@ class TestListAdvertisementsFilters: assert response.status_code == 200 data = response.json() assert len(data["items"]) == 1 + + +class TestAdvertisementSort: + """Tests for advertisement list sort parameters.""" + + def test_sort_by_time_default(self, client_no_auth, api_db_session): + """Default sort is received_at DESC.""" + now = datetime.now(timezone.utc) + ad_old = Advertisement( + public_key="aa" * 16, + name="Old", + adv_type="CLIENT", + received_at=now - timedelta(hours=1), + ) + ad_new = Advertisement( + public_key="bb" * 16, + name="New", + adv_type="CLIENT", + received_at=now, + ) + api_db_session.add_all([ad_old, ad_new]) + api_db_session.commit() + + response = client_no_auth.get("/api/v1/advertisements") + assert response.status_code == 200 + items = response.json()["items"] + assert items[0]["name"] == "New" + assert items[1]["name"] == "Old" + + def test_sort_by_time_asc(self, client_no_auth, api_db_session): + """sort=time&order=asc returns oldest first.""" + now = datetime.now(timezone.utc) + ad_old = Advertisement( + public_key="aa" * 16, + name="Old", + adv_type="CLIENT", + received_at=now - timedelta(hours=1), + ) + ad_new = Advertisement( + public_key="bb" * 16, + name="New", + adv_type="CLIENT", + received_at=now, + ) + api_db_session.add_all([ad_old, ad_new]) + api_db_session.commit() + + response = client_no_auth.get("/api/v1/advertisements?sort=time&order=asc") + assert response.status_code == 200 + items = response.json()["items"] + assert items[0]["name"] == "Old" + assert items[1]["name"] == "New" + + def test_sort_by_node_name(self, client_no_auth, api_db_session): + """sort=node_name sorts by display name (COALESCE).""" + from meshcore_hub.common.models import Node + + now = datetime.now(timezone.utc) + node_b = Node( + public_key="aa" * 16, + name="Bravo", + first_seen=now, + ) + node_a = Node( + public_key="bb" * 16, + name="Alpha", + first_seen=now, + ) + api_db_session.add_all([node_b, node_a]) + api_db_session.commit() + + ad_b = Advertisement( + public_key="aa" * 16, + name="AdBravo", + adv_type="CLIENT", + received_at=now, + node_id=node_b.id, + ) + ad_a = Advertisement( + public_key="bb" * 16, + name="AdAlpha", + adv_type="CLIENT", + received_at=now, + node_id=node_a.id, + ) + api_db_session.add_all([ad_b, ad_a]) + api_db_session.commit() + + response = client_no_auth.get("/api/v1/advertisements?sort=node_name&order=asc") + assert response.status_code == 200 + items = response.json()["items"] + assert items[0]["node_name"] == "Alpha" + assert items[1]["node_name"] == "Bravo" + + def test_sort_by_node_name_tag_priority(self, client_no_auth, api_db_session): + """Name tag takes priority over SourceNode.name in sort.""" + from meshcore_hub.common.models import Node, NodeTag + + now = datetime.now(timezone.utc) + node_b = Node( + public_key="aa" * 16, + name="Alpha", + first_seen=now, + ) + node_a = Node( + public_key="bb" * 16, + name="Bravo", + first_seen=now, + ) + api_db_session.add_all([node_b, node_a]) + api_db_session.commit() + + tag_b = NodeTag(node_id=node_b.id, key="name", value="Zebra") + tag_a = NodeTag(node_id=node_a.id, key="name", value="Aardvark") + api_db_session.add_all([tag_b, tag_a]) + api_db_session.commit() + + ad_b = Advertisement( + public_key="aa" * 16, + name="AdB", + adv_type="CLIENT", + received_at=now, + node_id=node_b.id, + ) + ad_a = Advertisement( + public_key="bb" * 16, + name="AdA", + adv_type="CLIENT", + received_at=now, + node_id=node_a.id, + ) + api_db_session.add_all([ad_b, ad_a]) + api_db_session.commit() + + response = client_no_auth.get("/api/v1/advertisements?sort=node_name&order=asc") + assert response.status_code == 200 + items = response.json()["items"] + assert items[0]["node_name"] == "Bravo" + assert items[1]["node_name"] == "Alpha" + + def test_sort_by_public_key(self, client_no_auth, api_db_session): + """sort=public_key orders by public_key.""" + now = datetime.now(timezone.utc) + ad_b = Advertisement( + public_key="bb" * 16, + name="Bravo", + adv_type="CLIENT", + received_at=now, + ) + ad_a = Advertisement( + public_key="aa" * 16, + name="Alpha", + adv_type="CLIENT", + received_at=now, + ) + api_db_session.add_all([ad_b, ad_a]) + api_db_session.commit() + + response = client_no_auth.get( + "/api/v1/advertisements?sort=public_key&order=asc" + ) + assert response.status_code == 200 + items = response.json()["items"] + assert items[0]["public_key"] == "aa" * 16 + + def test_sort_invalid_ignored(self, client_no_auth, api_db_session): + """Invalid sort value falls back to default (time desc).""" + now = datetime.now(timezone.utc) + ad_old = Advertisement( + public_key="aa" * 16, + name="Old", + adv_type="CLIENT", + received_at=now - timedelta(hours=1), + ) + ad_new = Advertisement( + public_key="bb" * 16, + name="New", + adv_type="CLIENT", + received_at=now, + ) + api_db_session.add_all([ad_old, ad_new]) + api_db_session.commit() + + response = client_no_auth.get("/api/v1/advertisements?sort=invalid_column") + assert response.status_code == 200 + items = response.json()["items"] + assert items[0]["name"] == "New" diff --git a/tests/test_api/test_messages.py b/tests/test_api/test_messages.py index 8240b6d..6be6af8 100644 --- a/tests/test_api/test_messages.py +++ b/tests/test_api/test_messages.py @@ -347,3 +347,121 @@ class TestListMessagesFilters: assert response.status_code == 200 data = response.json() assert len(data["items"]) == 0 + + +class TestMessageSort: + """Tests for message list sort parameters.""" + + def test_sort_by_time_default(self, client_no_auth, api_db_session): + """Default sort is received_at DESC.""" + now = datetime.now(timezone.utc) + msg_old = Message( + message_type="direct", + pubkey_prefix="aa", + text="Old msg", + received_at=now - timedelta(hours=1), + ) + msg_new = Message( + message_type="direct", + pubkey_prefix="bb", + text="New msg", + received_at=now, + ) + api_db_session.add_all([msg_old, msg_new]) + api_db_session.commit() + + response = client_no_auth.get("/api/v1/messages") + assert response.status_code == 200 + items = response.json()["items"] + assert items[0]["text"] == "New msg" + assert items[1]["text"] == "Old msg" + + def test_sort_by_type(self, client_no_auth, api_db_session): + """sort=type&order=asc sorts by message_type.""" + now = datetime.now(timezone.utc) + msg_ch = Message( + message_type="channel", + channel_idx=1, + text="Channel msg", + received_at=now, + ) + msg_ct = Message( + message_type="contact", + text="Contact msg", + received_at=now, + ) + api_db_session.add_all([msg_ch, msg_ct]) + api_db_session.commit() + + response = client_no_auth.get("/api/v1/messages?sort=type&order=asc") + assert response.status_code == 200 + items = response.json()["items"] + assert items[0]["message_type"] == "channel" + assert items[1]["message_type"] == "contact" + + def test_sort_by_from(self, client_no_auth, api_db_session): + """sort=from&order=asc sorts by pubkey_prefix.""" + now = datetime.now(timezone.utc) + msg_b = Message( + message_type="direct", + pubkey_prefix="bb_prefix", + text="From B", + received_at=now, + ) + msg_a = Message( + message_type="direct", + pubkey_prefix="aa_prefix", + text="From A", + received_at=now, + ) + api_db_session.add_all([msg_b, msg_a]) + api_db_session.commit() + + response = client_no_auth.get("/api/v1/messages?sort=from&order=asc") + assert response.status_code == 200 + items = response.json()["items"] + assert items[0]["text"] == "From A" + assert items[1]["text"] == "From B" + + def test_sort_by_message(self, client_no_auth, api_db_session): + """sort=message&order=asc sorts by text.""" + now = datetime.now(timezone.utc) + msg_b = Message( + message_type="direct", + text="Zebra message", + received_at=now, + ) + msg_a = Message( + message_type="direct", + text="Alpha message", + received_at=now, + ) + api_db_session.add_all([msg_b, msg_a]) + api_db_session.commit() + + response = client_no_auth.get("/api/v1/messages?sort=message&order=asc") + assert response.status_code == 200 + items = response.json()["items"] + assert items[0]["text"] == "Alpha message" + assert items[1]["text"] == "Zebra message" + + def test_sort_invalid_ignored(self, client_no_auth, api_db_session): + """Invalid sort value falls back to default (time desc).""" + now = datetime.now(timezone.utc) + msg_old = Message( + message_type="direct", + text="Old", + received_at=now - timedelta(hours=1), + ) + msg_new = Message( + message_type="direct", + text="New", + received_at=now, + ) + api_db_session.add_all([msg_old, msg_new]) + api_db_session.commit() + + response = client_no_auth.get("/api/v1/messages?sort=invalid_column") + assert response.status_code == 200 + items = response.json()["items"] + assert items[0]["text"] == "New" diff --git a/tests/test_api/test_nodes.py b/tests/test_api/test_nodes.py index 476d342..6d7580f 100644 --- a/tests/test_api/test_nodes.py +++ b/tests/test_api/test_nodes.py @@ -466,6 +466,234 @@ class TestNodeTags: assert data["value"] == "admin-val" +class TestNodeSort: + """Tests for node list sort parameters.""" + + def test_sort_by_name_default(self, client_no_auth, api_db_session): + """Default sort (no params) returns nodes alpha by display name.""" + from datetime import datetime, timezone + + from meshcore_hub.common.models import Node + + node_b = Node( + public_key="bb" * 32, + name="Bravo", + adv_type="CLIENT", + first_seen=datetime.now(timezone.utc), + last_seen=datetime.now(timezone.utc), + ) + node_a = Node( + public_key="aa" * 32, + name="Alpha", + adv_type="CLIENT", + first_seen=datetime.now(timezone.utc), + last_seen=datetime.now(timezone.utc), + ) + api_db_session.add_all([node_b, node_a]) + api_db_session.commit() + + response = client_no_auth.get("/api/v1/nodes") + assert response.status_code == 200 + items = response.json()["items"] + assert len(items) == 2 + assert items[0]["name"] == "Alpha" + assert items[1]["name"] == "Bravo" + + def test_sort_by_name_asc(self, client_no_auth, api_db_session): + """Explicit sort=name&order=asc.""" + from datetime import datetime, timezone + + from meshcore_hub.common.models import Node + + node_b = Node( + public_key="bb" * 32, + name="Bravo", + adv_type="CLIENT", + first_seen=datetime.now(timezone.utc), + ) + node_a = Node( + public_key="aa" * 32, + name="Alpha", + adv_type="CLIENT", + first_seen=datetime.now(timezone.utc), + ) + api_db_session.add_all([node_b, node_a]) + api_db_session.commit() + + response = client_no_auth.get("/api/v1/nodes?sort=name&order=asc") + assert response.status_code == 200 + items = response.json()["items"] + assert items[0]["name"] == "Alpha" + + def test_sort_by_name_desc(self, client_no_auth, api_db_session): + """sort=name&order=desc returns Z-to-A.""" + from datetime import datetime, timezone + + from meshcore_hub.common.models import Node + + node_a = Node( + public_key="aa" * 32, + name="Alpha", + adv_type="CLIENT", + first_seen=datetime.now(timezone.utc), + ) + node_b = Node( + public_key="bb" * 32, + name="Bravo", + adv_type="CLIENT", + first_seen=datetime.now(timezone.utc), + ) + api_db_session.add_all([node_a, node_b]) + api_db_session.commit() + + response = client_no_auth.get("/api/v1/nodes?sort=name&order=desc") + assert response.status_code == 200 + items = response.json()["items"] + assert items[0]["name"] == "Bravo" + assert items[1]["name"] == "Alpha" + + def test_sort_by_public_key(self, client_no_auth, api_db_session): + """sort=public_key orders by public_key.""" + from datetime import datetime, timezone + + from meshcore_hub.common.models import Node + + node_b = Node( + public_key="bb" * 32, + name="Alpha", + adv_type="CLIENT", + first_seen=datetime.now(timezone.utc), + ) + node_a = Node( + public_key="aa" * 32, + name="Bravo", + adv_type="CLIENT", + first_seen=datetime.now(timezone.utc), + ) + api_db_session.add_all([node_b, node_a]) + api_db_session.commit() + + response = client_no_auth.get("/api/v1/nodes?sort=public_key&order=asc") + assert response.status_code == 200 + items = response.json()["items"] + assert items[0]["public_key"] == "aa" * 32 + + def test_sort_by_last_seen(self, client_no_auth, api_db_session): + """sort=last_seen&order=asc returns oldest first.""" + from datetime import datetime, timedelta, timezone + + from meshcore_hub.common.models import Node + + now = datetime.now(timezone.utc) + node_old = Node( + public_key="aa" * 32, + name="Old", + adv_type="CLIENT", + first_seen=now - timedelta(days=2), + last_seen=now - timedelta(days=1), + ) + node_new = Node( + public_key="bb" * 32, + name="New", + adv_type="CLIENT", + first_seen=now, + last_seen=now, + ) + api_db_session.add_all([node_old, node_new]) + api_db_session.commit() + + response = client_no_auth.get("/api/v1/nodes?sort=last_seen&order=asc") + assert response.status_code == 200 + items = response.json()["items"] + assert items[0]["name"] == "Old" + assert items[1]["name"] == "New" + + def test_sort_name_tag_priority(self, client_no_auth, api_db_session): + """Name tag takes priority over node.name in sort.""" + from datetime import datetime, timezone + + from meshcore_hub.common.models import Node, NodeTag + + node_b = Node( + public_key="bb" * 32, + name="Alpha", + adv_type="CLIENT", + first_seen=datetime.now(timezone.utc), + ) + node_a = Node( + public_key="aa" * 32, + name="Bravo", + adv_type="CLIENT", + first_seen=datetime.now(timezone.utc), + ) + api_db_session.add_all([node_b, node_a]) + api_db_session.commit() + + tag_b = NodeTag(node_id=node_b.id, key="name", value="Zebra") + tag_a = NodeTag(node_id=node_a.id, key="name", value="Aardvark") + api_db_session.add_all([tag_b, tag_a]) + api_db_session.commit() + + response = client_no_auth.get("/api/v1/nodes?sort=name&order=asc") + assert response.status_code == 200 + items = response.json()["items"] + assert items[0]["name"] == "Bravo" + assert items[1]["name"] == "Alpha" + + def test_sort_invalid_ignored(self, client_no_auth, api_db_session): + """Invalid sort value falls back to default (name alpha).""" + from datetime import datetime, timezone + + from meshcore_hub.common.models import Node + + node_b = Node( + public_key="bb" * 32, + name="Bravo", + adv_type="CLIENT", + first_seen=datetime.now(timezone.utc), + ) + node_a = Node( + public_key="aa" * 32, + name="Alpha", + adv_type="CLIENT", + first_seen=datetime.now(timezone.utc), + ) + api_db_session.add_all([node_b, node_a]) + api_db_session.commit() + + response = client_no_auth.get("/api/v1/nodes?sort=invalid_column") + assert response.status_code == 200 + items = response.json()["items"] + assert items[0]["name"] == "Alpha" + + def test_sort_nodes_with_null_name(self, client_no_auth, api_db_session): + """Nodes with name=NULL sort by public_key via COALESCE fallback.""" + from datetime import datetime, timezone + + from meshcore_hub.common.models import Node + + node_no_name = Node( + public_key="bb" * 32, + name=None, + adv_type="CLIENT", + first_seen=datetime.now(timezone.utc), + ) + node_named = Node( + public_key="aa" * 32, + name="Alpha", + adv_type="CLIENT", + first_seen=datetime.now(timezone.utc), + ) + api_db_session.add_all([node_no_name, node_named]) + api_db_session.commit() + + response = client_no_auth.get("/api/v1/nodes?sort=name&order=asc") + assert response.status_code == 200 + items = response.json()["items"] + assert items[0]["name"] == "Alpha" + assert items[1]["name"] is None + + class TestTagValidation: """Unit tests for validate_and_coerce_tag_value."""
${t('entities.node')}${t('common.public_key')}${t('common.last_seen')}