feat: add clickable sort controls to list pages with alpha default for nodes

Add sort/order query parameters to Nodes, Advertisements, and Messages
API endpoints. Nodes default to alpha-by-name (via COALESCE of name tag,
node name, public key). Ads and Messages default to newest-first.

Frontend adds sortableTableHeader() component with asc/desc toggle
indicators. Sort state is preserved in URL params, surviving
auto-refresh and pagination.
This commit is contained in:
Louis King
2026-05-05 17:05:54 +01:00
parent 2e41f182be
commit 160ba6d5bf
12 changed files with 1311 additions and 23 deletions
@@ -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 `<th>` 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 `<th>` with an `<a>` 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`<th>
<a href=${url} class="link link-hover inline-flex items-center gap-1 no-underline"
@click=${(e) => { e.preventDefault(); navigate(url); }}>
${label}<span class="text-xs opacity-50">${indicator}</span>
</a>
</th>`;
}
```
**Not sortable:** Columns without a sort key render as plain `<th>` 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 `<th>` 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:
<thead><tr>
${sortable(t('entities.node'), 'name')}
${sortable(t('common.public_key'), 'public_key')}
${sortable(t('common.last_seen'), 'last_seen')}
</tr></thead>
```
**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:
<thead><tr>
${sortable(t('entities.node'), 'node_name')}
${sortable(t('common.public_key'), 'public_key')}
${sortable(t('common.time'), 'time')}
<th>${t('common.observers')}</th>
</tr></thead>
```
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:
<thead><tr>
${sortable(t('common.type'), 'type')}
${sortable(t('common.time'), 'time')}
${sortable(t('common.from'), 'from')}
${sortable(t('entities.message'), 'message')}
<th>${t('common.observers')}</th>
</tr></thead>
```
---
## 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 `<th>` with sortable headers |
| `src/meshcore_hub/web/static/js/spa/pages/advertisements.js` | Parse sort/order, pass to API, replace `<th>` with sortable headers |
| `src/meshcore_hub/web/static/js/spa/pages/messages.js` | Parse sort/order, pass to API, replace `<th>` 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
@@ -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 `<th><a>` 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 `<th>` 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 `<th>` elements with `sortableTableHeader()` calls for Node (`node_name`), Public Key, Time columns
- Keep Observers column as plain `<th>`
## 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 `<th>` elements with `sortableTableHeader()` calls for Type, Time, From, Message columns
- Keep Observers column as plain `<th>`
## 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`
+36 -1
View File
@@ -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()
+30 -1
View File
@@ -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()
+33 -2
View File
@@ -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()
@@ -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`<th>
<a href=${url} class="link link-hover inline-flex items-center gap-1 no-underline"
@click=${(e) => { e.preventDefault(); e.stopPropagation(); navigate(url); }}>
${label}<span class="text-xs opacity-50">${indicator}</span>
</a>
</th>`;
}
/**
* Get app config from the embedded window object.
* @returns {Object} App configuration
@@ -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}
<div class="lg:hidden space-y-3">
@@ -238,9 +247,9 @@ ${displayContent}`, container);
<table class="table table-zebra">
<thead>
<tr>
<th>${t('entities.node')}</th>
<th>${t('common.public_key')}</th>
<th>${t('common.time')}</th>
${sortable(t('entities.node'), 'node_name')}
${sortable(t('common.public_key'), 'public_key')}
${sortable(t('common.time'), 'time')}
<th>${t('common.observers')}</th>
</tr>
</thead>
@@ -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}
<div class="lg:hidden space-y-3">
@@ -386,10 +394,10 @@ ${displayContent}`, container);
<table class="table table-zebra">
<thead>
<tr>
<th>${t('common.type')}</th>
<th>${t('common.time')}</th>
<th>${t('common.from')}</th>
<th>${t('entities.message')}</th>
${sortable(t('common.type'), 'type')}
${sortable(t('common.time'), 'time')}
${sortable(t('common.from'), 'from')}
${sortable(t('entities.message'), 'message')}
<th>${t('common.observers')}</th>
</tr>
</thead>
@@ -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}
<div class="lg:hidden space-y-3">
@@ -186,9 +194,9 @@ ${displayContent}`, container);
<table class="table table-zebra">
<thead>
<tr>
<th>${t('entities.node')}</th>
<th>${t('common.public_key')}</th>
<th>${t('common.last_seen')}</th>
${sortable(t('entities.node'), 'name')}
${sortable(t('common.public_key'), 'public_key')}
${sortable(t('common.last_seen'), 'last_seen')}
</tr>
</thead>
<tbody>
+187
View File
@@ -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"
+118
View File
@@ -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"
+228
View File
@@ -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."""