mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-07-21 09:02:24 +02:00
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:
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user