diff --git a/src/meshcore_hub/__main__.py b/src/meshcore_hub/__main__.py
index 81d5b45..fd8464d 100644
--- a/src/meshcore_hub/__main__.py
+++ b/src/meshcore_hub/__main__.py
@@ -32,71 +32,11 @@ def cli(ctx: click.Context, log_level: str) -> None:
# Import and register component CLIs
from meshcore_hub.interface.cli import interface
from meshcore_hub.collector.cli import collector
+from meshcore_hub.api.cli import api
cli.add_command(interface)
cli.add_command(collector)
-
-
-@cli.command()
-@click.option(
- "--host",
- type=str,
- default="0.0.0.0",
- envvar="API_HOST",
- help="API server host",
-)
-@click.option(
- "--port",
- type=int,
- default=8000,
- envvar="API_PORT",
- help="API server port",
-)
-@click.option(
- "--database-url",
- type=str,
- default="sqlite:///./meshcore.db",
- envvar="DATABASE_URL",
- help="Database connection URL",
-)
-@click.option(
- "--read-key",
- type=str,
- default=None,
- envvar="API_READ_KEY",
- help="Read-only API key",
-)
-@click.option(
- "--admin-key",
- type=str,
- default=None,
- envvar="API_ADMIN_KEY",
- help="Admin API key",
-)
-@click.option(
- "--reload",
- is_flag=True,
- default=False,
- help="Enable auto-reload for development",
-)
-def api(
- host: str,
- port: int,
- database_url: str,
- read_key: str | None,
- admin_key: str | None,
- reload: bool,
-) -> None:
- """Run the REST API server.
-
- Provides REST API endpoints for querying data and sending commands.
- """
- click.echo("Starting API server...")
- click.echo(f"Listening on: {host}:{port}")
- click.echo(f"Database: {database_url}")
- click.echo(f"Read key configured: {read_key is not None}")
- click.echo(f"Admin key configured: {admin_key is not None}")
- click.echo("API component not yet implemented.")
+cli.add_command(api)
@cli.command()
diff --git a/src/meshcore_hub/api/app.py b/src/meshcore_hub/api/app.py
new file mode 100644
index 0000000..a2f5857
--- /dev/null
+++ b/src/meshcore_hub/api/app.py
@@ -0,0 +1,123 @@
+"""FastAPI application for MeshCore Hub API."""
+
+import logging
+from contextlib import asynccontextmanager
+from typing import AsyncGenerator
+
+from fastapi import FastAPI
+from fastapi.middleware.cors import CORSMiddleware
+
+from meshcore_hub import __version__
+from meshcore_hub.common.database import DatabaseManager
+
+logger = logging.getLogger(__name__)
+
+# Global database manager (set during startup)
+_db_manager: DatabaseManager | None = None
+
+
+def get_db_manager() -> DatabaseManager:
+ """Get the global database manager."""
+ if _db_manager is None:
+ raise RuntimeError("Database not initialized")
+ return _db_manager
+
+
+@asynccontextmanager
+async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
+ """Application lifespan handler."""
+ global _db_manager
+
+ # Get database URL from app state
+ database_url = getattr(app.state, "database_url", "sqlite:///./meshcore.db")
+
+ # Initialize database
+ logger.info(f"Initializing database: {database_url}")
+ _db_manager = DatabaseManager(database_url)
+ _db_manager.create_tables()
+
+ yield
+
+ # Cleanup
+ if _db_manager:
+ _db_manager.dispose()
+ _db_manager = None
+ logger.info("Database connection closed")
+
+
+def create_app(
+ database_url: str = "sqlite:///./meshcore.db",
+ read_key: str | None = None,
+ admin_key: str | None = None,
+ mqtt_host: str = "localhost",
+ mqtt_port: int = 1883,
+ mqtt_prefix: str = "meshcore",
+ cors_origins: list[str] | None = None,
+) -> FastAPI:
+ """Create and configure the FastAPI application.
+
+ Args:
+ database_url: Database connection URL
+ read_key: Read-only API key
+ admin_key: Admin API key
+ mqtt_host: MQTT broker host
+ mqtt_port: MQTT broker port
+ mqtt_prefix: MQTT topic prefix
+ cors_origins: Allowed CORS origins
+
+ Returns:
+ Configured FastAPI application
+ """
+ app = FastAPI(
+ title="MeshCore Hub API",
+ description="REST API for querying MeshCore network data and sending commands",
+ version=__version__,
+ lifespan=lifespan,
+ docs_url="/api/docs",
+ redoc_url="/api/redoc",
+ openapi_url="/api/openapi.json",
+ )
+
+ # Store configuration in app state
+ app.state.database_url = database_url
+ app.state.read_key = read_key
+ app.state.admin_key = admin_key
+ app.state.mqtt_host = mqtt_host
+ app.state.mqtt_port = mqtt_port
+ app.state.mqtt_prefix = mqtt_prefix
+
+ # Configure CORS
+ if cors_origins is None:
+ cors_origins = ["*"]
+
+ app.add_middleware(
+ CORSMiddleware,
+ allow_origins=cors_origins,
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+ )
+
+ # Include routers
+ from meshcore_hub.api.routes import api_router
+
+ app.include_router(api_router, prefix="/api/v1")
+
+ # Health check endpoints
+ @app.get("/health", tags=["Health"])
+ async def health() -> dict:
+ """Basic health check."""
+ return {"status": "healthy", "version": __version__}
+
+ @app.get("/health/ready", tags=["Health"])
+ async def health_ready() -> dict:
+ """Readiness check including database."""
+ try:
+ db = get_db_manager()
+ with db.session_scope() as session:
+ session.execute("SELECT 1")
+ return {"status": "ready", "database": "connected"}
+ except Exception as e:
+ return {"status": "not_ready", "database": str(e)}
+
+ return app
diff --git a/src/meshcore_hub/api/auth.py b/src/meshcore_hub/api/auth.py
new file mode 100644
index 0000000..dddb38d
--- /dev/null
+++ b/src/meshcore_hub/api/auth.py
@@ -0,0 +1,137 @@
+"""Authentication middleware for the API."""
+
+import logging
+from typing import Annotated
+
+from fastapi import Depends, HTTPException, Request, status
+from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
+
+logger = logging.getLogger(__name__)
+
+# Security scheme
+security = HTTPBearer(auto_error=False)
+
+
+def get_api_keys(request: Request) -> tuple[str | None, str | None]:
+ """Get API keys from app state.
+
+ Args:
+ request: FastAPI request
+
+ Returns:
+ Tuple of (read_key, admin_key)
+ """
+ return (
+ getattr(request.app.state, "read_key", None),
+ getattr(request.app.state, "admin_key", None),
+ )
+
+
+async def get_current_token(
+ credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)],
+) -> str | None:
+ """Extract bearer token from request.
+
+ Args:
+ credentials: HTTP authorization credentials
+
+ Returns:
+ Token string or None
+ """
+ if credentials is None:
+ return None
+ return credentials.credentials
+
+
+async def require_read(
+ request: Request,
+ token: Annotated[str | None, Depends(get_current_token)],
+) -> str | None:
+ """Require read-level authentication.
+
+ Allows access if:
+ - No API keys are configured (open access)
+ - Token matches read key
+ - Token matches admin key
+
+ Args:
+ request: FastAPI request
+ token: Bearer token
+
+ Returns:
+ Token string
+
+ Raises:
+ HTTPException: If authentication fails
+ """
+ read_key, admin_key = get_api_keys(request)
+
+ # If no keys configured, allow access
+ if not read_key and not admin_key:
+ return token
+
+ # Require token if keys are configured
+ if not token:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Authentication required",
+ headers={"WWW-Authenticate": "Bearer"},
+ )
+
+ # Check if token matches any key
+ if token == read_key or token == admin_key:
+ return token
+
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail="Invalid API key",
+ )
+
+
+async def require_admin(
+ request: Request,
+ token: Annotated[str | None, Depends(get_current_token)],
+) -> str:
+ """Require admin-level authentication.
+
+ Allows access if:
+ - No admin key is configured (open access)
+ - Token matches admin key
+
+ Args:
+ request: FastAPI request
+ token: Bearer token
+
+ Returns:
+ Token string
+
+ Raises:
+ HTTPException: If authentication fails
+ """
+ read_key, admin_key = get_api_keys(request)
+
+ # If no admin key configured, allow access
+ if not admin_key:
+ return token or ""
+
+ # Require token
+ if not token:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Authentication required",
+ headers={"WWW-Authenticate": "Bearer"},
+ )
+
+ # Check if token matches admin key
+ if token == admin_key:
+ return token
+
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail="Admin access required",
+ )
+
+
+# Dependency types for use in routes
+RequireRead = Annotated[str | None, Depends(require_read)]
+RequireAdmin = Annotated[str, Depends(require_admin)]
diff --git a/src/meshcore_hub/api/cli.py b/src/meshcore_hub/api/cli.py
new file mode 100644
index 0000000..9df0011
--- /dev/null
+++ b/src/meshcore_hub/api/cli.py
@@ -0,0 +1,157 @@
+"""API CLI commands."""
+
+import click
+
+
+@click.command()
+@click.option(
+ "--host",
+ type=str,
+ default="0.0.0.0",
+ envvar="API_HOST",
+ help="API server host",
+)
+@click.option(
+ "--port",
+ type=int,
+ default=8000,
+ envvar="API_PORT",
+ help="API server port",
+)
+@click.option(
+ "--database-url",
+ type=str,
+ default="sqlite:///./meshcore.db",
+ envvar="DATABASE_URL",
+ help="Database connection URL",
+)
+@click.option(
+ "--read-key",
+ type=str,
+ default=None,
+ envvar="API_READ_KEY",
+ help="Read-only API key (optional, enables read-level auth)",
+)
+@click.option(
+ "--admin-key",
+ type=str,
+ default=None,
+ envvar="API_ADMIN_KEY",
+ help="Admin API key (optional, enables admin-level auth)",
+)
+@click.option(
+ "--mqtt-host",
+ type=str,
+ default="localhost",
+ envvar="MQTT_HOST",
+ help="MQTT broker host for commands",
+)
+@click.option(
+ "--mqtt-port",
+ type=int,
+ default=1883,
+ envvar="MQTT_PORT",
+ help="MQTT broker port",
+)
+@click.option(
+ "--mqtt-prefix",
+ type=str,
+ default="meshcore",
+ envvar="MQTT_TOPIC_PREFIX",
+ help="MQTT topic prefix",
+)
+@click.option(
+ "--cors-origins",
+ type=str,
+ default=None,
+ envvar="CORS_ORIGINS",
+ help="Comma-separated list of allowed CORS origins",
+)
+@click.option(
+ "--reload",
+ is_flag=True,
+ default=False,
+ help="Enable auto-reload for development",
+)
+@click.pass_context
+def api(
+ ctx: click.Context,
+ host: str,
+ port: int,
+ database_url: str,
+ read_key: str | None,
+ admin_key: str | None,
+ mqtt_host: str,
+ mqtt_port: int,
+ mqtt_prefix: str,
+ cors_origins: str | None,
+ reload: bool,
+) -> None:
+ """Run the REST API server.
+
+ Provides REST API endpoints for querying mesh network data and sending
+ commands to devices via MQTT.
+
+ Examples:
+
+ # Run with defaults (no auth)
+ meshcore-hub api
+
+ # Run with authentication
+ meshcore-hub api --read-key secret --admin-key supersecret
+
+ # Run with CORS for web frontend
+ meshcore-hub api --cors-origins "http://localhost:8080,http://localhost:3000"
+
+ # Development mode with auto-reload
+ meshcore-hub api --reload
+ """
+ import uvicorn
+
+ from meshcore_hub.api.app import create_app
+
+ click.echo("=" * 50)
+ click.echo("MeshCore Hub API Server")
+ click.echo("=" * 50)
+ click.echo(f"Host: {host}")
+ click.echo(f"Port: {port}")
+ click.echo(f"Database: {database_url}")
+ click.echo(f"MQTT: {mqtt_host}:{mqtt_port} (prefix: {mqtt_prefix})")
+ click.echo(f"Read key configured: {read_key is not None}")
+ click.echo(f"Admin key configured: {admin_key is not None}")
+ click.echo(f"CORS origins: {cors_origins or 'none'}")
+ click.echo(f"Reload mode: {reload}")
+ click.echo("=" * 50)
+
+ # Parse CORS origins
+ origins_list: list[str] | None = None
+ if cors_origins:
+ origins_list = [o.strip() for o in cors_origins.split(",")]
+
+ if reload:
+ # For development, use uvicorn's reload feature
+ # We need to pass app as string for reload to work
+ click.echo("\nStarting in development mode with auto-reload...")
+ click.echo("Note: Using default settings for reload mode.")
+
+ uvicorn.run(
+ "meshcore_hub.api.app:create_app",
+ host=host,
+ port=port,
+ reload=True,
+ factory=True,
+ )
+ else:
+ # For production, create app directly
+ app = create_app(
+ database_url=database_url,
+ read_key=read_key,
+ admin_key=admin_key,
+ mqtt_host=mqtt_host,
+ mqtt_port=mqtt_port,
+ mqtt_prefix=mqtt_prefix,
+ cors_origins=origins_list,
+ )
+
+ click.echo("\nStarting API server...")
+ uvicorn.run(app, host=host, port=port)
diff --git a/src/meshcore_hub/api/dependencies.py b/src/meshcore_hub/api/dependencies.py
new file mode 100644
index 0000000..5b68652
--- /dev/null
+++ b/src/meshcore_hub/api/dependencies.py
@@ -0,0 +1,73 @@
+"""FastAPI dependencies for the API."""
+
+import logging
+from typing import Annotated, Generator
+
+from fastapi import Depends, Request
+from sqlalchemy.orm import Session
+
+from meshcore_hub.common.database import DatabaseManager
+from meshcore_hub.common.mqtt import MQTTClient, MQTTConfig
+
+logger = logging.getLogger(__name__)
+
+
+def get_db_manager(request: Request) -> DatabaseManager:
+ """Get database manager from app.
+
+ Args:
+ request: FastAPI request
+
+ Returns:
+ DatabaseManager instance
+ """
+ from meshcore_hub.api.app import get_db_manager as _get_db_manager
+
+ return _get_db_manager()
+
+
+def get_db_session(
+ db_manager: Annotated[DatabaseManager, Depends(get_db_manager)],
+) -> Generator[Session, None, None]:
+ """Get a database session.
+
+ Args:
+ db_manager: Database manager
+
+ Yields:
+ Database session
+ """
+ session = db_manager.get_session()
+ try:
+ yield session
+ finally:
+ session.close()
+
+
+def get_mqtt_client(request: Request) -> MQTTClient:
+ """Get an MQTT client for publishing commands.
+
+ Args:
+ request: FastAPI request
+
+ Returns:
+ MQTTClient instance
+ """
+ mqtt_host = getattr(request.app.state, "mqtt_host", "localhost")
+ mqtt_port = getattr(request.app.state, "mqtt_port", 1883)
+ mqtt_prefix = getattr(request.app.state, "mqtt_prefix", "meshcore")
+
+ config = MQTTConfig(
+ host=mqtt_host,
+ port=mqtt_port,
+ prefix=mqtt_prefix,
+ client_id="meshcore-api",
+ )
+
+ client = MQTTClient(config)
+ return client
+
+
+# Dependency types for use in routes
+DbSession = Annotated[Session, Depends(get_db_session)]
+MqttClient = Annotated[MQTTClient, Depends(get_mqtt_client)]
diff --git a/src/meshcore_hub/api/routes/__init__.py b/src/meshcore_hub/api/routes/__init__.py
index 1d3b629..c9a5934 100644
--- a/src/meshcore_hub/api/routes/__init__.py
+++ b/src/meshcore_hub/api/routes/__init__.py
@@ -1 +1,28 @@
"""API route handlers."""
+
+from fastapi import APIRouter
+
+from meshcore_hub.api.routes.nodes import router as nodes_router
+from meshcore_hub.api.routes.node_tags import router as node_tags_router
+from meshcore_hub.api.routes.messages import router as messages_router
+from meshcore_hub.api.routes.advertisements import router as advertisements_router
+from meshcore_hub.api.routes.trace_paths import router as trace_paths_router
+from meshcore_hub.api.routes.telemetry import router as telemetry_router
+from meshcore_hub.api.routes.commands import router as commands_router
+from meshcore_hub.api.routes.dashboard import router as dashboard_router
+
+api_router = APIRouter()
+
+# Include all routers
+api_router.include_router(nodes_router, prefix="/nodes", tags=["Nodes"])
+api_router.include_router(node_tags_router, tags=["Node Tags"])
+api_router.include_router(messages_router, prefix="/messages", tags=["Messages"])
+api_router.include_router(
+ advertisements_router, prefix="/advertisements", tags=["Advertisements"]
+)
+api_router.include_router(
+ trace_paths_router, prefix="/trace-paths", tags=["Trace Paths"]
+)
+api_router.include_router(telemetry_router, prefix="/telemetry", tags=["Telemetry"])
+api_router.include_router(commands_router, prefix="/commands", tags=["Commands"])
+api_router.include_router(dashboard_router, tags=["Dashboard"])
diff --git a/src/meshcore_hub/api/routes/advertisements.py b/src/meshcore_hub/api/routes/advertisements.py
new file mode 100644
index 0000000..378b505
--- /dev/null
+++ b/src/meshcore_hub/api/routes/advertisements.py
@@ -0,0 +1,71 @@
+"""Advertisement API routes."""
+
+from datetime import datetime
+from typing import Optional
+
+from fastapi import APIRouter, HTTPException, Query
+from sqlalchemy import func, select
+
+from meshcore_hub.api.auth import RequireRead
+from meshcore_hub.api.dependencies import DbSession
+from meshcore_hub.common.models import Advertisement
+from meshcore_hub.common.schemas.messages import AdvertisementList, AdvertisementRead
+
+router = APIRouter()
+
+
+@router.get("", response_model=AdvertisementList)
+async def list_advertisements(
+ _: RequireRead,
+ session: DbSession,
+ public_key: Optional[str] = Query(None, description="Filter by public key"),
+ since: Optional[datetime] = Query(None, description="Start timestamp"),
+ until: Optional[datetime] = Query(None, description="End timestamp"),
+ limit: int = Query(50, ge=1, le=100, description="Page size"),
+ offset: int = Query(0, ge=0, description="Page offset"),
+) -> AdvertisementList:
+ """List advertisements with filtering and pagination."""
+ # Build query
+ query = select(Advertisement)
+
+ if public_key:
+ query = query.where(Advertisement.public_key == public_key)
+
+ if since:
+ query = query.where(Advertisement.received_at >= since)
+
+ if until:
+ query = query.where(Advertisement.received_at <= until)
+
+ # Get total count
+ count_query = select(func.count()).select_from(query.subquery())
+ total = session.execute(count_query).scalar() or 0
+
+ # Apply pagination
+ query = query.order_by(Advertisement.received_at.desc()).offset(offset).limit(limit)
+
+ # Execute
+ advertisements = session.execute(query).scalars().all()
+
+ return AdvertisementList(
+ items=[AdvertisementRead.model_validate(a) for a in advertisements],
+ total=total,
+ limit=limit,
+ offset=offset,
+ )
+
+
+@router.get("/{advertisement_id}", response_model=AdvertisementRead)
+async def get_advertisement(
+ _: RequireRead,
+ session: DbSession,
+ advertisement_id: str,
+) -> AdvertisementRead:
+ """Get a single advertisement by ID."""
+ query = select(Advertisement).where(Advertisement.id == advertisement_id)
+ advertisement = session.execute(query).scalar_one_or_none()
+
+ if not advertisement:
+ raise HTTPException(status_code=404, detail="Advertisement not found")
+
+ return AdvertisementRead.model_validate(advertisement)
diff --git a/src/meshcore_hub/api/routes/commands.py b/src/meshcore_hub/api/routes/commands.py
new file mode 100644
index 0000000..3c62f4d
--- /dev/null
+++ b/src/meshcore_hub/api/routes/commands.py
@@ -0,0 +1,149 @@
+"""Command API routes for sending messages to the mesh network."""
+
+import logging
+import time
+
+from fastapi import APIRouter
+
+from meshcore_hub.api.auth import RequireAdmin
+from meshcore_hub.api.dependencies import MqttClient
+from meshcore_hub.common.schemas.commands import (
+ CommandResponse,
+ SendAdvertCommand,
+ SendChannelMessageCommand,
+ SendMessageCommand,
+)
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter()
+
+
+@router.post("/send-message", response_model=CommandResponse)
+async def send_message(
+ _: RequireAdmin,
+ mqtt: MqttClient,
+ command: SendMessageCommand,
+) -> CommandResponse:
+ """Send a direct message to a node.
+
+ Publishes a send_msg command to MQTT for the sender interface to process.
+ """
+ try:
+ # Connect to MQTT
+ mqtt.connect()
+ mqtt.start_background()
+
+ # Build payload
+ payload = {
+ "destination": command.destination,
+ "text": command.text,
+ "timestamp": command.timestamp or int(time.time()),
+ }
+
+ # Publish to wildcard topic (any sender can pick it up)
+ mqtt.publish_command("+", "send_msg", payload)
+
+ # Cleanup
+ mqtt.stop()
+ mqtt.disconnect()
+
+ logger.info(f"Published send_msg command to {command.destination[:12]}...")
+
+ return CommandResponse(
+ success=True,
+ message=f"Message queued for {command.destination[:12]}...",
+ )
+
+ except Exception as e:
+ logger.error(f"Failed to send message: {e}")
+ return CommandResponse(
+ success=False,
+ message=f"Failed to send message: {str(e)}",
+ )
+
+
+@router.post("/send-channel-message", response_model=CommandResponse)
+async def send_channel_message(
+ _: RequireAdmin,
+ mqtt: MqttClient,
+ command: SendChannelMessageCommand,
+) -> CommandResponse:
+ """Send a message to a channel.
+
+ Publishes a send_channel_msg command to MQTT for the sender interface to process.
+ """
+ try:
+ # Connect to MQTT
+ mqtt.connect()
+ mqtt.start_background()
+
+ # Build payload
+ payload = {
+ "channel_idx": command.channel_idx,
+ "text": command.text,
+ "timestamp": command.timestamp or int(time.time()),
+ }
+
+ # Publish to wildcard topic
+ mqtt.publish_command("+", "send_channel_msg", payload)
+
+ # Cleanup
+ mqtt.stop()
+ mqtt.disconnect()
+
+ logger.info(f"Published send_channel_msg command to channel {command.channel_idx}")
+
+ return CommandResponse(
+ success=True,
+ message=f"Message queued for channel {command.channel_idx}",
+ )
+
+ except Exception as e:
+ logger.error(f"Failed to send channel message: {e}")
+ return CommandResponse(
+ success=False,
+ message=f"Failed to send channel message: {str(e)}",
+ )
+
+
+@router.post("/send-advertisement", response_model=CommandResponse)
+async def send_advertisement(
+ _: RequireAdmin,
+ mqtt: MqttClient,
+ command: SendAdvertCommand,
+) -> CommandResponse:
+ """Send a node advertisement.
+
+ Publishes a send_advert command to MQTT for the sender interface to process.
+ """
+ try:
+ # Connect to MQTT
+ mqtt.connect()
+ mqtt.start_background()
+
+ # Build payload
+ payload = {
+ "flood": command.flood,
+ }
+
+ # Publish to wildcard topic
+ mqtt.publish_command("+", "send_advert", payload)
+
+ # Cleanup
+ mqtt.stop()
+ mqtt.disconnect()
+
+ logger.info(f"Published send_advert command (flood={command.flood})")
+
+ return CommandResponse(
+ success=True,
+ message=f"Advertisement queued (flood={command.flood})",
+ )
+
+ except Exception as e:
+ logger.error(f"Failed to send advertisement: {e}")
+ return CommandResponse(
+ success=False,
+ message=f"Failed to send advertisement: {str(e)}",
+ )
diff --git a/src/meshcore_hub/api/routes/dashboard.py b/src/meshcore_hub/api/routes/dashboard.py
new file mode 100644
index 0000000..679c56f
--- /dev/null
+++ b/src/meshcore_hub/api/routes/dashboard.py
@@ -0,0 +1,237 @@
+"""Dashboard API routes."""
+
+from datetime import datetime, timedelta, timezone
+
+from fastapi import APIRouter, Request
+from fastapi.responses import HTMLResponse
+from sqlalchemy import func, select
+
+from meshcore_hub.api.auth import RequireRead
+from meshcore_hub.api.dependencies import DbSession
+from meshcore_hub.common.models import Advertisement, Message, Node
+from meshcore_hub.common.schemas.messages import DashboardStats
+
+router = APIRouter()
+
+
+@router.get("/stats", response_model=DashboardStats)
+async def get_stats(
+ _: RequireRead,
+ session: DbSession,
+) -> DashboardStats:
+ """Get dashboard statistics."""
+ now = datetime.now(timezone.utc)
+ today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
+ yesterday = now - timedelta(days=1)
+
+ # Total nodes
+ total_nodes = session.execute(
+ select(func.count()).select_from(Node)
+ ).scalar() or 0
+
+ # Active nodes (last 24h)
+ active_nodes = session.execute(
+ select(func.count()).select_from(Node).where(Node.last_seen >= yesterday)
+ ).scalar() or 0
+
+ # Total messages
+ total_messages = session.execute(
+ select(func.count()).select_from(Message)
+ ).scalar() or 0
+
+ # Messages today
+ messages_today = session.execute(
+ select(func.count())
+ .select_from(Message)
+ .where(Message.received_at >= today_start)
+ ).scalar() or 0
+
+ # Total advertisements
+ total_advertisements = session.execute(
+ select(func.count()).select_from(Advertisement)
+ ).scalar() or 0
+
+ # Channel message counts
+ channel_counts_query = (
+ select(Message.channel_idx, func.count())
+ .where(Message.message_type == "channel")
+ .where(Message.channel_idx.isnot(None))
+ .group_by(Message.channel_idx)
+ )
+ channel_results = session.execute(channel_counts_query).all()
+ channel_message_counts = {
+ int(channel): int(count) for channel, count in channel_results
+ }
+
+ return DashboardStats(
+ total_nodes=total_nodes,
+ active_nodes=active_nodes,
+ total_messages=total_messages,
+ messages_today=messages_today,
+ total_advertisements=total_advertisements,
+ channel_message_counts=channel_message_counts,
+ )
+
+
+@router.get("/dashboard", response_class=HTMLResponse)
+async def dashboard(
+ request: Request,
+ session: DbSession,
+) -> HTMLResponse:
+ """Simple HTML dashboard page."""
+ now = datetime.now(timezone.utc)
+ today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
+ yesterday = now - timedelta(days=1)
+
+ # Get stats
+ total_nodes = session.execute(
+ select(func.count()).select_from(Node)
+ ).scalar() or 0
+
+ active_nodes = session.execute(
+ select(func.count()).select_from(Node).where(Node.last_seen >= yesterday)
+ ).scalar() or 0
+
+ total_messages = session.execute(
+ select(func.count()).select_from(Message)
+ ).scalar() or 0
+
+ messages_today = session.execute(
+ select(func.count())
+ .select_from(Message)
+ .where(Message.received_at >= today_start)
+ ).scalar() or 0
+
+ # Get recent nodes
+ recent_nodes = session.execute(
+ select(Node).order_by(Node.last_seen.desc()).limit(10)
+ ).scalars().all()
+
+ # Get recent messages
+ recent_messages = session.execute(
+ select(Message).order_by(Message.received_at.desc()).limit(10)
+ ).scalars().all()
+
+ # Build HTML
+ html = f"""
+
+
+
+ MeshCore Hub Dashboard
+
+
+
+
+
+
+
+
MeshCore Hub Dashboard
+
Last updated: {now.strftime('%Y-%m-%d %H:%M:%S UTC')}
+
+
+
+
Total Nodes
+
{total_nodes}
+
+
+
Active Nodes (24h)
+
{active_nodes}
+
+
+
Total Messages
+
{total_messages}
+
+
+
Messages Today
+
{messages_today}
+
+
+
+
+
Recent Nodes
+
+
+
+ | Name |
+ Public Key |
+ Type |
+ Last Seen |
+
+
+
+ {"".join(f'''
+
+ | {n.name or '-'} |
+ {n.public_key[:16]}... |
+ {n.adv_type or '-'} |
+ {n.last_seen.strftime('%Y-%m-%d %H:%M') if n.last_seen else '-'} |
+
+ ''' for n in recent_nodes)}
+
+
+
+
+
+
Recent Messages
+
+
+
+ | Type |
+ From/Channel |
+ Text |
+ Received |
+
+
+
+ {"".join(f'''
+
+ | {m.message_type} |
+ {m.pubkey_prefix or f'Ch {m.channel_idx}' or '-'} |
+ {m.text[:50]}{'...' if len(m.text) > 50 else ''} |
+ {m.received_at.strftime('%Y-%m-%d %H:%M') if m.received_at else '-'} |
+
+ ''' for m in recent_messages)}
+
+
+
+
+
+
+"""
+ return HTMLResponse(content=html)
diff --git a/src/meshcore_hub/api/routes/messages.py b/src/meshcore_hub/api/routes/messages.py
new file mode 100644
index 0000000..e2f255a
--- /dev/null
+++ b/src/meshcore_hub/api/routes/messages.py
@@ -0,0 +1,83 @@
+"""Message API routes."""
+
+from datetime import datetime
+from typing import Optional
+
+from fastapi import APIRouter, HTTPException, Query
+from sqlalchemy import func, select
+
+from meshcore_hub.api.auth import RequireRead
+from meshcore_hub.api.dependencies import DbSession
+from meshcore_hub.common.models import Message
+from meshcore_hub.common.schemas.messages import MessageList, MessageRead
+
+router = APIRouter()
+
+
+@router.get("", response_model=MessageList)
+async def list_messages(
+ _: RequireRead,
+ session: DbSession,
+ type: Optional[str] = Query(None, description="Filter by message type"),
+ pubkey_prefix: Optional[str] = Query(None, description="Filter by sender prefix"),
+ channel_idx: Optional[int] = Query(None, description="Filter by channel"),
+ 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"),
+ limit: int = Query(50, ge=1, le=100, description="Page size"),
+ offset: int = Query(0, ge=0, description="Page offset"),
+) -> MessageList:
+ """List messages with filtering and pagination."""
+ # Build query
+ query = select(Message)
+
+ if type:
+ query = query.where(Message.message_type == type)
+
+ if pubkey_prefix:
+ query = query.where(Message.pubkey_prefix == pubkey_prefix)
+
+ if channel_idx is not None:
+ query = query.where(Message.channel_idx == channel_idx)
+
+ if since:
+ query = query.where(Message.received_at >= since)
+
+ if until:
+ query = query.where(Message.received_at <= until)
+
+ if search:
+ query = query.where(Message.text.ilike(f"%{search}%"))
+
+ # Get total count
+ count_query = select(func.count()).select_from(query.subquery())
+ total = session.execute(count_query).scalar() or 0
+
+ # Apply pagination
+ query = query.order_by(Message.received_at.desc()).offset(offset).limit(limit)
+
+ # Execute
+ messages = session.execute(query).scalars().all()
+
+ return MessageList(
+ items=[MessageRead.model_validate(m) for m in messages],
+ total=total,
+ limit=limit,
+ offset=offset,
+ )
+
+
+@router.get("/{message_id}", response_model=MessageRead)
+async def get_message(
+ _: RequireRead,
+ session: DbSession,
+ message_id: str,
+) -> MessageRead:
+ """Get a single message by ID."""
+ query = select(Message).where(Message.id == message_id)
+ message = session.execute(query).scalar_one_or_none()
+
+ if not message:
+ raise HTTPException(status_code=404, detail="Message not found")
+
+ return MessageRead.model_validate(message)
diff --git a/src/meshcore_hub/api/routes/node_tags.py b/src/meshcore_hub/api/routes/node_tags.py
new file mode 100644
index 0000000..2da9c2b
--- /dev/null
+++ b/src/meshcore_hub/api/routes/node_tags.py
@@ -0,0 +1,131 @@
+"""Node tag API routes."""
+
+from fastapi import APIRouter, HTTPException
+from sqlalchemy import select
+
+from meshcore_hub.api.auth import RequireAdmin, RequireRead
+from meshcore_hub.api.dependencies import DbSession
+from meshcore_hub.common.models import Node, NodeTag
+from meshcore_hub.common.schemas.nodes import NodeTagCreate, NodeTagRead, NodeTagUpdate
+
+router = APIRouter()
+
+
+@router.get("/nodes/{public_key}/tags", response_model=list[NodeTagRead])
+async def list_node_tags(
+ _: RequireRead,
+ session: DbSession,
+ public_key: str,
+) -> list[NodeTagRead]:
+ """List all tags for a node."""
+ # Find node
+ node_query = select(Node).where(Node.public_key == public_key)
+ node = session.execute(node_query).scalar_one_or_none()
+
+ if not node:
+ raise HTTPException(status_code=404, detail="Node not found")
+
+ return [NodeTagRead.model_validate(t) for t in node.tags]
+
+
+@router.post("/nodes/{public_key}/tags", response_model=NodeTagRead, status_code=201)
+async def create_node_tag(
+ _: RequireAdmin,
+ session: DbSession,
+ public_key: str,
+ tag: NodeTagCreate,
+) -> NodeTagRead:
+ """Create a new tag for a node."""
+ # Find node
+ node_query = select(Node).where(Node.public_key == public_key)
+ node = session.execute(node_query).scalar_one_or_none()
+
+ if not node:
+ raise HTTPException(status_code=404, detail="Node not found")
+
+ # Check if tag already exists
+ existing_query = select(NodeTag).where(
+ (NodeTag.node_id == node.id) & (NodeTag.key == tag.key)
+ )
+ existing = session.execute(existing_query).scalar_one_or_none()
+
+ if existing:
+ raise HTTPException(status_code=409, detail="Tag already exists")
+
+ # Create tag
+ node_tag = NodeTag(
+ node_id=node.id,
+ key=tag.key,
+ value=tag.value,
+ value_type=tag.value_type,
+ )
+ session.add(node_tag)
+ session.commit()
+ session.refresh(node_tag)
+
+ return NodeTagRead.model_validate(node_tag)
+
+
+@router.put("/nodes/{public_key}/tags/{key}", response_model=NodeTagRead)
+async def update_node_tag(
+ _: RequireAdmin,
+ session: DbSession,
+ public_key: str,
+ key: str,
+ tag: NodeTagUpdate,
+) -> NodeTagRead:
+ """Update a node tag."""
+ # Find node
+ node_query = select(Node).where(Node.public_key == public_key)
+ node = session.execute(node_query).scalar_one_or_none()
+
+ if not node:
+ raise HTTPException(status_code=404, detail="Node not found")
+
+ # Find tag
+ tag_query = select(NodeTag).where(
+ (NodeTag.node_id == node.id) & (NodeTag.key == key)
+ )
+ node_tag = session.execute(tag_query).scalar_one_or_none()
+
+ if not node_tag:
+ raise HTTPException(status_code=404, detail="Tag not found")
+
+ # Update tag
+ if tag.value is not None:
+ node_tag.value = tag.value
+ if tag.value_type is not None:
+ node_tag.value_type = tag.value_type
+
+ session.commit()
+ session.refresh(node_tag)
+
+ return NodeTagRead.model_validate(node_tag)
+
+
+@router.delete("/nodes/{public_key}/tags/{key}", status_code=204)
+async def delete_node_tag(
+ _: RequireAdmin,
+ session: DbSession,
+ public_key: str,
+ key: str,
+) -> None:
+ """Delete a node tag."""
+ # Find node
+ node_query = select(Node).where(Node.public_key == public_key)
+ node = session.execute(node_query).scalar_one_or_none()
+
+ if not node:
+ raise HTTPException(status_code=404, detail="Node not found")
+
+ # Find and delete tag
+ tag_query = select(NodeTag).where(
+ (NodeTag.node_id == node.id) & (NodeTag.key == key)
+ )
+ node_tag = session.execute(tag_query).scalar_one_or_none()
+
+ if not node_tag:
+ raise HTTPException(status_code=404, detail="Tag not found")
+
+ session.delete(node_tag)
+ session.commit()
diff --git a/src/meshcore_hub/api/routes/nodes.py b/src/meshcore_hub/api/routes/nodes.py
new file mode 100644
index 0000000..27a1d66
--- /dev/null
+++ b/src/meshcore_hub/api/routes/nodes.py
@@ -0,0 +1,68 @@
+"""Node API routes."""
+
+from typing import Optional
+
+from fastapi import APIRouter, HTTPException, Query
+from sqlalchemy import func, select
+
+from meshcore_hub.api.auth import RequireRead
+from meshcore_hub.api.dependencies import DbSession
+from meshcore_hub.common.models import Node
+from meshcore_hub.common.schemas.nodes import NodeList, NodeRead
+
+router = APIRouter()
+
+
+@router.get("", response_model=NodeList)
+async def list_nodes(
+ _: RequireRead,
+ session: DbSession,
+ search: Optional[str] = Query(None, description="Search in name or public key"),
+ adv_type: Optional[str] = Query(None, description="Filter by advertisement type"),
+ limit: int = Query(50, ge=1, le=100, description="Page size"),
+ offset: int = Query(0, ge=0, description="Page offset"),
+) -> NodeList:
+ """List all nodes with pagination and filtering."""
+ # Build query
+ query = select(Node)
+
+ if search:
+ query = query.where(
+ (Node.name.ilike(f"%{search}%")) | (Node.public_key.ilike(f"%{search}%"))
+ )
+
+ if adv_type:
+ query = query.where(Node.adv_type == adv_type)
+
+ # Get total count
+ count_query = select(func.count()).select_from(query.subquery())
+ total = session.execute(count_query).scalar() or 0
+
+ # Apply pagination
+ query = query.order_by(Node.last_seen.desc()).offset(offset).limit(limit)
+
+ # Execute
+ nodes = session.execute(query).scalars().all()
+
+ return NodeList(
+ items=[NodeRead.model_validate(n) for n in nodes],
+ total=total,
+ limit=limit,
+ offset=offset,
+ )
+
+
+@router.get("/{public_key}", response_model=NodeRead)
+async def get_node(
+ _: RequireRead,
+ session: DbSession,
+ public_key: str,
+) -> NodeRead:
+ """Get a single node by public key."""
+ query = select(Node).where(Node.public_key == public_key)
+ node = session.execute(query).scalar_one_or_none()
+
+ if not node:
+ raise HTTPException(status_code=404, detail="Node not found")
+
+ return NodeRead.model_validate(node)
diff --git a/src/meshcore_hub/api/routes/telemetry.py b/src/meshcore_hub/api/routes/telemetry.py
new file mode 100644
index 0000000..0d954cb
--- /dev/null
+++ b/src/meshcore_hub/api/routes/telemetry.py
@@ -0,0 +1,71 @@
+"""Telemetry API routes."""
+
+from datetime import datetime
+from typing import Optional
+
+from fastapi import APIRouter, HTTPException, Query
+from sqlalchemy import func, select
+
+from meshcore_hub.api.auth import RequireRead
+from meshcore_hub.api.dependencies import DbSession
+from meshcore_hub.common.models import Telemetry
+from meshcore_hub.common.schemas.messages import TelemetryList, TelemetryRead
+
+router = APIRouter()
+
+
+@router.get("", response_model=TelemetryList)
+async def list_telemetry(
+ _: RequireRead,
+ session: DbSession,
+ node_public_key: Optional[str] = Query(None, description="Filter by node"),
+ since: Optional[datetime] = Query(None, description="Start timestamp"),
+ until: Optional[datetime] = Query(None, description="End timestamp"),
+ limit: int = Query(50, ge=1, le=100, description="Page size"),
+ offset: int = Query(0, ge=0, description="Page offset"),
+) -> TelemetryList:
+ """List telemetry records with filtering and pagination."""
+ # Build query
+ query = select(Telemetry)
+
+ if node_public_key:
+ query = query.where(Telemetry.node_public_key == node_public_key)
+
+ if since:
+ query = query.where(Telemetry.received_at >= since)
+
+ if until:
+ query = query.where(Telemetry.received_at <= until)
+
+ # Get total count
+ count_query = select(func.count()).select_from(query.subquery())
+ total = session.execute(count_query).scalar() or 0
+
+ # Apply pagination
+ query = query.order_by(Telemetry.received_at.desc()).offset(offset).limit(limit)
+
+ # Execute
+ records = session.execute(query).scalars().all()
+
+ return TelemetryList(
+ items=[TelemetryRead.model_validate(t) for t in records],
+ total=total,
+ limit=limit,
+ offset=offset,
+ )
+
+
+@router.get("/{telemetry_id}", response_model=TelemetryRead)
+async def get_telemetry(
+ _: RequireRead,
+ session: DbSession,
+ telemetry_id: str,
+) -> TelemetryRead:
+ """Get a single telemetry record by ID."""
+ query = select(Telemetry).where(Telemetry.id == telemetry_id)
+ telemetry = session.execute(query).scalar_one_or_none()
+
+ if not telemetry:
+ raise HTTPException(status_code=404, detail="Telemetry record not found")
+
+ return TelemetryRead.model_validate(telemetry)
diff --git a/src/meshcore_hub/api/routes/trace_paths.py b/src/meshcore_hub/api/routes/trace_paths.py
new file mode 100644
index 0000000..38a5b42
--- /dev/null
+++ b/src/meshcore_hub/api/routes/trace_paths.py
@@ -0,0 +1,67 @@
+"""Trace path API routes."""
+
+from datetime import datetime
+from typing import Optional
+
+from fastapi import APIRouter, HTTPException, Query
+from sqlalchemy import func, select
+
+from meshcore_hub.api.auth import RequireRead
+from meshcore_hub.api.dependencies import DbSession
+from meshcore_hub.common.models import TracePath
+from meshcore_hub.common.schemas.messages import TracePathList, TracePathRead
+
+router = APIRouter()
+
+
+@router.get("", response_model=TracePathList)
+async def list_trace_paths(
+ _: RequireRead,
+ session: DbSession,
+ since: Optional[datetime] = Query(None, description="Start timestamp"),
+ until: Optional[datetime] = Query(None, description="End timestamp"),
+ limit: int = Query(50, ge=1, le=100, description="Page size"),
+ offset: int = Query(0, ge=0, description="Page offset"),
+) -> TracePathList:
+ """List trace paths with filtering and pagination."""
+ # Build query
+ query = select(TracePath)
+
+ if since:
+ query = query.where(TracePath.received_at >= since)
+
+ if until:
+ query = query.where(TracePath.received_at <= until)
+
+ # Get total count
+ count_query = select(func.count()).select_from(query.subquery())
+ total = session.execute(count_query).scalar() or 0
+
+ # Apply pagination
+ query = query.order_by(TracePath.received_at.desc()).offset(offset).limit(limit)
+
+ # Execute
+ trace_paths = session.execute(query).scalars().all()
+
+ return TracePathList(
+ items=[TracePathRead.model_validate(t) for t in trace_paths],
+ total=total,
+ limit=limit,
+ offset=offset,
+ )
+
+
+@router.get("/{trace_path_id}", response_model=TracePathRead)
+async def get_trace_path(
+ _: RequireRead,
+ session: DbSession,
+ trace_path_id: str,
+) -> TracePathRead:
+ """Get a single trace path by ID."""
+ query = select(TracePath).where(TracePath.id == trace_path_id)
+ trace_path = session.execute(query).scalar_one_or_none()
+
+ if not trace_path:
+ raise HTTPException(status_code=404, detail="Trace path not found")
+
+ return TracePathRead.model_validate(trace_path)
diff --git a/src/meshcore_hub/common/models/telemetry.py b/src/meshcore_hub/common/models/telemetry.py
index 991edf3..7bf1341 100644
--- a/src/meshcore_hub/common/models/telemetry.py
+++ b/src/meshcore_hub/common/models/telemetry.py
@@ -56,7 +56,6 @@ class Telemetry(Base, UUIDMixin, TimestampMixin):
)
__table_args__ = (
- Index("ix_telemetry_node_public_key", "node_public_key"),
Index("ix_telemetry_received_at", "received_at"),
)
diff --git a/tests/test_api/conftest.py b/tests/test_api/conftest.py
new file mode 100644
index 0000000..55e5ccf
--- /dev/null
+++ b/tests/test_api/conftest.py
@@ -0,0 +1,262 @@
+"""API test fixtures."""
+
+import os
+import tempfile
+from datetime import datetime, timezone
+from unittest.mock import MagicMock, patch
+
+import pytest
+from fastapi.testclient import TestClient
+from sqlalchemy import create_engine
+from sqlalchemy.orm import sessionmaker
+
+from meshcore_hub.api.app import create_app
+from meshcore_hub.api.dependencies import get_db_session, get_mqtt_client, get_db_manager
+from meshcore_hub.common.database import DatabaseManager
+from meshcore_hub.common.models import (
+ Advertisement,
+ Base,
+ Message,
+ Node,
+ NodeTag,
+ Telemetry,
+ TracePath,
+)
+
+
+@pytest.fixture
+def test_db_path():
+ """Create a temporary database file path."""
+ fd, path = tempfile.mkstemp(suffix=".db")
+ os.close(fd)
+ yield path
+ # Cleanup
+ if os.path.exists(path):
+ os.unlink(path)
+
+
+@pytest.fixture
+def api_db_engine(test_db_path):
+ """Create a SQLite database engine for API testing."""
+ db_url = f"sqlite:///{test_db_path}"
+ engine = create_engine(
+ db_url,
+ connect_args={"check_same_thread": False},
+ )
+ Base.metadata.create_all(engine)
+ yield engine
+ Base.metadata.drop_all(engine)
+ engine.dispose()
+
+
+@pytest.fixture
+def api_db_session(api_db_engine):
+ """Create a database session for API testing."""
+ Session = sessionmaker(bind=api_db_engine)
+ session = Session()
+ yield session
+ session.close()
+
+
+@pytest.fixture
+def mock_mqtt():
+ """Create a mock MQTT client."""
+ mock = MagicMock()
+ mock.connect.return_value = None
+ mock.start_background.return_value = None
+ mock.stop.return_value = None
+ mock.disconnect.return_value = None
+ mock.publish_command.return_value = None
+ return mock
+
+
+@pytest.fixture
+def mock_db_manager(api_db_engine):
+ """Create a mock database manager using the test engine."""
+ manager = MagicMock(spec=DatabaseManager)
+ Session = sessionmaker(bind=api_db_engine)
+ manager.get_session = lambda: Session()
+ return manager
+
+
+@pytest.fixture
+def app_no_auth(test_db_path, api_db_engine, mock_mqtt, mock_db_manager):
+ """Create a FastAPI app with no authentication required."""
+ db_url = f"sqlite:///{test_db_path}"
+
+ # Patch the global db_manager to avoid lifespan issues
+ with patch("meshcore_hub.api.app._db_manager", mock_db_manager):
+ app = create_app(
+ database_url=db_url,
+ read_key=None,
+ admin_key=None,
+ )
+
+ # Create session maker for this test engine
+ Session = sessionmaker(bind=api_db_engine)
+
+ def override_get_db_manager(request=None):
+ return mock_db_manager
+
+ def override_get_db_session():
+ session = Session()
+ try:
+ yield session
+ finally:
+ session.close()
+
+ def override_get_mqtt_client(request=None):
+ return mock_mqtt
+
+ app.dependency_overrides[get_db_manager] = override_get_db_manager
+ app.dependency_overrides[get_db_session] = override_get_db_session
+ app.dependency_overrides[get_mqtt_client] = override_get_mqtt_client
+
+ yield app
+
+
+@pytest.fixture
+def app_with_auth(test_db_path, api_db_engine, mock_mqtt, mock_db_manager):
+ """Create a FastAPI app with authentication enabled."""
+ db_url = f"sqlite:///{test_db_path}"
+
+ with patch("meshcore_hub.api.app._db_manager", mock_db_manager):
+ app = create_app(
+ database_url=db_url,
+ read_key="test-read-key",
+ admin_key="test-admin-key",
+ )
+
+ Session = sessionmaker(bind=api_db_engine)
+
+ def override_get_db_manager(request=None):
+ return mock_db_manager
+
+ def override_get_db_session():
+ session = Session()
+ try:
+ yield session
+ finally:
+ session.close()
+
+ def override_get_mqtt_client(request=None):
+ return mock_mqtt
+
+ app.dependency_overrides[get_db_manager] = override_get_db_manager
+ app.dependency_overrides[get_db_session] = override_get_db_session
+ app.dependency_overrides[get_mqtt_client] = override_get_mqtt_client
+
+ yield app
+
+
+@pytest.fixture
+def client_no_auth(app_no_auth, mock_db_manager):
+ """Create a test client with no authentication.
+
+ Uses raise_server_exceptions=False to skip lifespan events.
+ """
+ # Don't use context manager to skip lifespan
+ client = TestClient(app_no_auth, raise_server_exceptions=True)
+ yield client
+
+
+@pytest.fixture
+def client_with_auth(app_with_auth, mock_db_manager):
+ """Create a test client with authentication enabled.
+
+ Uses raise_server_exceptions=False to skip lifespan events.
+ """
+ client = TestClient(app_with_auth, raise_server_exceptions=True)
+ yield client
+
+
+@pytest.fixture
+def sample_node(api_db_session):
+ """Create a sample node in the database."""
+ node = Node(
+ public_key="abc123def456abc123def456abc123de",
+ name="Test Node",
+ adv_type="REPEATER",
+ first_seen=datetime.now(timezone.utc),
+ last_seen=datetime.now(timezone.utc),
+ )
+ api_db_session.add(node)
+ api_db_session.commit()
+ api_db_session.refresh(node)
+ return node
+
+
+@pytest.fixture
+def sample_node_tag(api_db_session, sample_node):
+ """Create a sample node tag in the database."""
+ tag = NodeTag(
+ node_id=sample_node.id,
+ key="environment",
+ value="production",
+ )
+ api_db_session.add(tag)
+ api_db_session.commit()
+ api_db_session.refresh(tag)
+ return tag
+
+
+@pytest.fixture
+def sample_message(api_db_session):
+ """Create a sample message in the database."""
+ message = Message(
+ message_type="direct",
+ pubkey_prefix="abc123",
+ text="Hello World",
+ received_at=datetime.now(timezone.utc),
+ )
+ api_db_session.add(message)
+ api_db_session.commit()
+ api_db_session.refresh(message)
+ return message
+
+
+@pytest.fixture
+def sample_advertisement(api_db_session):
+ """Create a sample advertisement in the database."""
+ advert = Advertisement(
+ public_key="abc123def456abc123def456abc123de",
+ name="TestNode",
+ adv_type="REPEATER",
+ received_at=datetime.now(timezone.utc),
+ )
+ api_db_session.add(advert)
+ api_db_session.commit()
+ api_db_session.refresh(advert)
+ return advert
+
+
+@pytest.fixture
+def sample_telemetry(api_db_session):
+ """Create a sample telemetry record in the database."""
+ telemetry = Telemetry(
+ node_public_key="abc123def456abc123def456abc123de",
+ parsed_data={
+ "battery_level": 85.5,
+ "temperature": 25.3,
+ },
+ received_at=datetime.now(timezone.utc),
+ )
+ api_db_session.add(telemetry)
+ api_db_session.commit()
+ api_db_session.refresh(telemetry)
+ return telemetry
+
+
+@pytest.fixture
+def sample_trace_path(api_db_session):
+ """Create a sample trace path in the database."""
+ trace = TracePath(
+ initiator_tag=12345,
+ path_hashes=["abc123", "def456", "ghi789"],
+ hop_count=3,
+ received_at=datetime.now(timezone.utc),
+ )
+ api_db_session.add(trace)
+ api_db_session.commit()
+ api_db_session.refresh(trace)
+ return trace
diff --git a/tests/test_api/test_advertisements.py b/tests/test_api/test_advertisements.py
new file mode 100644
index 0000000..803a13f
--- /dev/null
+++ b/tests/test_api/test_advertisements.py
@@ -0,0 +1,62 @@
+"""Tests for advertisement API routes."""
+
+import pytest
+
+
+class TestListAdvertisements:
+ """Tests for GET /advertisements endpoint."""
+
+ def test_list_advertisements_empty(self, client_no_auth):
+ """Test listing advertisements when database is empty."""
+ response = client_no_auth.get("/api/v1/advertisements")
+ assert response.status_code == 200
+ data = response.json()
+ assert data["items"] == []
+ assert data["total"] == 0
+
+ def test_list_advertisements_with_data(self, client_no_auth, sample_advertisement):
+ """Test listing advertisements with data in database."""
+ response = client_no_auth.get("/api/v1/advertisements")
+ assert response.status_code == 200
+ data = response.json()
+ assert len(data["items"]) == 1
+ assert data["total"] == 1
+ assert data["items"][0]["public_key"] == sample_advertisement.public_key
+ assert data["items"][0]["adv_type"] == sample_advertisement.adv_type
+
+ def test_list_advertisements_filter_by_public_key(
+ self, client_no_auth, sample_advertisement
+ ):
+ """Test filtering advertisements by public key."""
+ response = client_no_auth.get(
+ f"/api/v1/advertisements?public_key={sample_advertisement.public_key}"
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert len(data["items"]) == 1
+
+ response = client_no_auth.get(
+ "/api/v1/advertisements?public_key=nonexistent"
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert len(data["items"]) == 0
+
+
+class TestGetAdvertisement:
+ """Tests for GET /advertisements/{id} endpoint."""
+
+ def test_get_advertisement_success(self, client_no_auth, sample_advertisement):
+ """Test getting a specific advertisement."""
+ response = client_no_auth.get(
+ f"/api/v1/advertisements/{sample_advertisement.id}"
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert data["id"] == sample_advertisement.id
+ assert data["public_key"] == sample_advertisement.public_key
+
+ def test_get_advertisement_not_found(self, client_no_auth):
+ """Test getting a non-existent advertisement."""
+ response = client_no_auth.get("/api/v1/advertisements/nonexistent-id")
+ assert response.status_code == 404
diff --git a/tests/test_api/test_auth.py b/tests/test_api/test_auth.py
new file mode 100644
index 0000000..b084090
--- /dev/null
+++ b/tests/test_api/test_auth.py
@@ -0,0 +1,106 @@
+"""Tests for API authentication."""
+
+import pytest
+
+
+class TestAuthenticationFlow:
+ """Tests for authentication behavior."""
+
+ def test_no_auth_when_keys_not_configured(self, client_no_auth):
+ """Test that no auth is required when keys are not configured."""
+ # All endpoints should work without auth
+ response = client_no_auth.get("/api/v1/nodes")
+ assert response.status_code == 200
+
+ response = client_no_auth.get("/api/v1/messages")
+ assert response.status_code == 200
+
+ response = client_no_auth.post(
+ "/api/v1/commands/send-message",
+ json={
+ "destination": "abc123def456abc123def456abc123de",
+ "text": "Test",
+ },
+ )
+ assert response.status_code == 200
+
+ def test_read_endpoints_accept_read_key(self, client_with_auth):
+ """Test that read endpoints accept read key."""
+ response = client_with_auth.get(
+ "/api/v1/nodes",
+ headers={"Authorization": "Bearer test-read-key"},
+ )
+ assert response.status_code == 200
+
+ def test_read_endpoints_accept_admin_key(self, client_with_auth):
+ """Test that read endpoints accept admin key."""
+ response = client_with_auth.get(
+ "/api/v1/nodes",
+ headers={"Authorization": "Bearer test-admin-key"},
+ )
+ assert response.status_code == 200
+
+ def test_admin_endpoints_reject_read_key(self, client_with_auth):
+ """Test that admin endpoints reject read key."""
+ response = client_with_auth.post(
+ "/api/v1/commands/send-message",
+ json={
+ "destination": "abc123def456abc123def456abc123de",
+ "text": "Test",
+ },
+ headers={"Authorization": "Bearer test-read-key"},
+ )
+ assert response.status_code == 403
+
+ def test_admin_endpoints_accept_admin_key(self, client_with_auth):
+ """Test that admin endpoints accept admin key."""
+ response = client_with_auth.post(
+ "/api/v1/commands/send-message",
+ json={
+ "destination": "abc123def456abc123def456abc123de",
+ "text": "Test",
+ },
+ headers={"Authorization": "Bearer test-admin-key"},
+ )
+ assert response.status_code == 200
+
+ def test_invalid_key_rejected(self, client_with_auth):
+ """Test that invalid keys are rejected."""
+ response = client_with_auth.get(
+ "/api/v1/nodes",
+ headers={"Authorization": "Bearer invalid-key"},
+ )
+ assert response.status_code == 401
+
+ def test_missing_bearer_prefix_rejected(self, client_with_auth):
+ """Test that tokens without Bearer prefix are rejected."""
+ response = client_with_auth.get(
+ "/api/v1/nodes",
+ headers={"Authorization": "test-read-key"},
+ )
+ assert response.status_code == 401
+
+ def test_empty_auth_header_rejected(self, client_with_auth):
+ """Test that empty auth headers are rejected."""
+ response = client_with_auth.get(
+ "/api/v1/nodes",
+ headers={"Authorization": ""},
+ )
+ assert response.status_code == 401
+
+
+class TestHealthEndpoint:
+ """Tests for health check endpoint."""
+
+ def test_health_no_auth(self, client_no_auth):
+ """Test health endpoint without auth."""
+ response = client_no_auth.get("/health")
+ assert response.status_code == 200
+ data = response.json()
+ assert data["status"] == "healthy"
+
+ def test_health_with_auth_configured(self, client_with_auth):
+ """Test health endpoint works even when auth is configured."""
+ # Health endpoint should always be accessible
+ response = client_with_auth.get("/health")
+ assert response.status_code == 200
diff --git a/tests/test_api/test_commands.py b/tests/test_api/test_commands.py
new file mode 100644
index 0000000..ecfd0c1
--- /dev/null
+++ b/tests/test_api/test_commands.py
@@ -0,0 +1,118 @@
+"""Tests for command API routes."""
+
+import pytest
+
+
+class TestSendMessage:
+ """Tests for POST /commands/send-message endpoint."""
+
+ def test_send_message_success(self, client_no_auth, mock_mqtt):
+ """Test sending a direct message."""
+ response = client_no_auth.post(
+ "/api/v1/commands/send-message",
+ json={
+ "destination": "abc123def456abc123def456abc123de",
+ "text": "Hello World",
+ },
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert data["success"] is True
+ assert "queued" in data["message"].lower()
+
+ def test_send_message_requires_admin(self, client_with_auth):
+ """Test sending message requires admin authentication."""
+ # Without auth
+ response = client_with_auth.post(
+ "/api/v1/commands/send-message",
+ json={
+ "destination": "abc123def456abc123def456abc123de",
+ "text": "Hello",
+ },
+ )
+ assert response.status_code == 401
+
+ # With read key (not admin)
+ response = client_with_auth.post(
+ "/api/v1/commands/send-message",
+ json={
+ "destination": "abc123def456abc123def456abc123de",
+ "text": "Hello",
+ },
+ headers={"Authorization": "Bearer test-read-key"},
+ )
+ assert response.status_code == 403
+
+ # With admin key
+ response = client_with_auth.post(
+ "/api/v1/commands/send-message",
+ json={
+ "destination": "abc123def456abc123def456abc123de",
+ "text": "Hello",
+ },
+ headers={"Authorization": "Bearer test-admin-key"},
+ )
+ assert response.status_code == 200
+
+
+class TestSendChannelMessage:
+ """Tests for POST /commands/send-channel-message endpoint."""
+
+ def test_send_channel_message_success(self, client_no_auth, mock_mqtt):
+ """Test sending a channel message."""
+ response = client_no_auth.post(
+ "/api/v1/commands/send-channel-message",
+ json={
+ "channel_idx": 1,
+ "text": "Hello Channel",
+ },
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert data["success"] is True
+ assert "channel 1" in data["message"].lower()
+
+ def test_send_channel_message_requires_admin(self, client_with_auth):
+ """Test sending channel message requires admin authentication."""
+ response = client_with_auth.post(
+ "/api/v1/commands/send-channel-message",
+ json={
+ "channel_idx": 1,
+ "text": "Hello",
+ },
+ )
+ assert response.status_code == 401
+
+
+class TestSendAdvertisement:
+ """Tests for POST /commands/send-advertisement endpoint."""
+
+ def test_send_advertisement_success(self, client_no_auth, mock_mqtt):
+ """Test sending an advertisement."""
+ response = client_no_auth.post(
+ "/api/v1/commands/send-advertisement",
+ json={"flood": False},
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert data["success"] is True
+ assert "advertisement" in data["message"].lower()
+
+ def test_send_advertisement_with_flood(self, client_no_auth, mock_mqtt):
+ """Test sending an advertisement with flood enabled."""
+ response = client_no_auth.post(
+ "/api/v1/commands/send-advertisement",
+ json={"flood": True},
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert data["success"] is True
+ assert "flood=True" in data["message"]
+
+ def test_send_advertisement_requires_admin(self, client_with_auth):
+ """Test sending advertisement requires admin authentication."""
+ response = client_with_auth.post(
+ "/api/v1/commands/send-advertisement",
+ json={"flood": False},
+ )
+ assert response.status_code == 401
diff --git a/tests/test_api/test_dashboard.py b/tests/test_api/test_dashboard.py
new file mode 100644
index 0000000..7d04408
--- /dev/null
+++ b/tests/test_api/test_dashboard.py
@@ -0,0 +1,62 @@
+"""Tests for dashboard API routes."""
+
+import pytest
+
+
+class TestDashboardStats:
+ """Tests for GET /dashboard/stats endpoint."""
+
+ def test_get_stats_empty(self, client_no_auth):
+ """Test getting stats with empty database."""
+ response = client_no_auth.get("/api/v1/dashboard/stats")
+ assert response.status_code == 200
+ data = response.json()
+ assert data["total_nodes"] == 0
+ assert data["active_nodes"] == 0
+ assert data["total_messages"] == 0
+ assert data["messages_today"] == 0
+ assert data["total_advertisements"] == 0
+ assert data["channel_message_counts"] == {}
+
+ def test_get_stats_with_data(
+ self, client_no_auth, sample_node, sample_message, sample_advertisement
+ ):
+ """Test getting stats with data in database."""
+ response = client_no_auth.get("/api/v1/dashboard/stats")
+ assert response.status_code == 200
+ data = response.json()
+ assert data["total_nodes"] == 1
+ assert data["active_nodes"] == 1 # Node was just created
+ assert data["total_messages"] == 1
+ assert data["total_advertisements"] == 1
+
+
+class TestDashboardHtml:
+ """Tests for GET /dashboard/dashboard endpoint."""
+
+ def test_dashboard_html_response(self, client_no_auth):
+ """Test dashboard returns HTML."""
+ response = client_no_auth.get("/api/v1/dashboard/dashboard")
+ assert response.status_code == 200
+ assert "text/html" in response.headers["content-type"]
+ assert "" in response.text
+ assert "MeshCore Hub Dashboard" in response.text
+
+ def test_dashboard_contains_stats(
+ self, client_no_auth, sample_node, sample_message
+ ):
+ """Test dashboard HTML contains stat values."""
+ response = client_no_auth.get("/api/v1/dashboard/dashboard")
+ assert response.status_code == 200
+ # Check that stats are present
+ assert "Total Nodes" in response.text
+ assert "Active Nodes" in response.text
+ assert "Total Messages" in response.text
+
+ def test_dashboard_contains_recent_data(self, client_no_auth, sample_node):
+ """Test dashboard HTML contains recent nodes."""
+ response = client_no_auth.get("/api/v1/dashboard/dashboard")
+ assert response.status_code == 200
+ assert "Recent Nodes" in response.text
+ # The node name should appear in the table
+ assert sample_node.name in response.text
diff --git a/tests/test_api/test_messages.py b/tests/test_api/test_messages.py
new file mode 100644
index 0000000..3d3b878
--- /dev/null
+++ b/tests/test_api/test_messages.py
@@ -0,0 +1,62 @@
+"""Tests for message API routes."""
+
+import pytest
+
+
+class TestListMessages:
+ """Tests for GET /messages endpoint."""
+
+ def test_list_messages_empty(self, client_no_auth):
+ """Test listing messages when database is empty."""
+ response = client_no_auth.get("/api/v1/messages")
+ assert response.status_code == 200
+ data = response.json()
+ assert data["items"] == []
+ assert data["total"] == 0
+
+ def test_list_messages_with_data(self, client_no_auth, sample_message):
+ """Test listing messages with data in database."""
+ response = client_no_auth.get("/api/v1/messages")
+ assert response.status_code == 200
+ data = response.json()
+ assert len(data["items"]) == 1
+ assert data["total"] == 1
+ assert data["items"][0]["text"] == sample_message.text
+ assert data["items"][0]["message_type"] == sample_message.message_type
+
+ def test_list_messages_filter_by_type(self, client_no_auth, sample_message):
+ """Test filtering messages by type."""
+ response = client_no_auth.get("/api/v1/messages?message_type=direct")
+ assert response.status_code == 200
+ data = response.json()
+ assert len(data["items"]) == 1
+
+ response = client_no_auth.get("/api/v1/messages?message_type=channel")
+ assert response.status_code == 200
+ data = response.json()
+ assert len(data["items"]) == 0
+
+ def test_list_messages_pagination(self, client_no_auth):
+ """Test message list pagination parameters."""
+ response = client_no_auth.get("/api/v1/messages?limit=25&offset=10")
+ assert response.status_code == 200
+ data = response.json()
+ assert data["limit"] == 25
+ assert data["offset"] == 10
+
+
+class TestGetMessage:
+ """Tests for GET /messages/{id} endpoint."""
+
+ def test_get_message_success(self, client_no_auth, sample_message):
+ """Test getting a specific message."""
+ response = client_no_auth.get(f"/api/v1/messages/{sample_message.id}")
+ assert response.status_code == 200
+ data = response.json()
+ assert data["id"] == sample_message.id
+ assert data["text"] == sample_message.text
+
+ def test_get_message_not_found(self, client_no_auth):
+ """Test getting a non-existent message."""
+ response = client_no_auth.get("/api/v1/messages/nonexistent-id")
+ assert response.status_code == 404
diff --git a/tests/test_api/test_nodes.py b/tests/test_api/test_nodes.py
new file mode 100644
index 0000000..1804f90
--- /dev/null
+++ b/tests/test_api/test_nodes.py
@@ -0,0 +1,136 @@
+"""Tests for node API routes."""
+
+import pytest
+
+
+class TestListNodes:
+ """Tests for GET /nodes endpoint."""
+
+ def test_list_nodes_empty(self, client_no_auth):
+ """Test listing nodes when database is empty."""
+ response = client_no_auth.get("/api/v1/nodes")
+ assert response.status_code == 200
+ data = response.json()
+ assert data["items"] == []
+ assert data["total"] == 0
+
+ def test_list_nodes_with_data(self, client_no_auth, sample_node):
+ """Test listing nodes with data in database."""
+ response = client_no_auth.get("/api/v1/nodes")
+ assert response.status_code == 200
+ data = response.json()
+ assert len(data["items"]) == 1
+ assert data["total"] == 1
+ assert data["items"][0]["public_key"] == sample_node.public_key
+ assert data["items"][0]["name"] == sample_node.name
+
+ def test_list_nodes_pagination(self, client_no_auth, sample_node):
+ """Test node list pagination parameters."""
+ response = client_no_auth.get("/api/v1/nodes?limit=10&offset=0")
+ assert response.status_code == 200
+ data = response.json()
+ assert data["limit"] == 10
+ assert data["offset"] == 0
+
+ def test_list_nodes_with_auth_required(self, client_with_auth):
+ """Test listing nodes requires auth when configured."""
+ # Without auth header
+ response = client_with_auth.get("/api/v1/nodes")
+ assert response.status_code == 401
+
+ # With read key
+ response = client_with_auth.get(
+ "/api/v1/nodes",
+ headers={"Authorization": "Bearer test-read-key"},
+ )
+ assert response.status_code == 200
+
+
+class TestGetNode:
+ """Tests for GET /nodes/{public_key} endpoint."""
+
+ def test_get_node_success(self, client_no_auth, sample_node):
+ """Test getting a specific node."""
+ response = client_no_auth.get(f"/api/v1/nodes/{sample_node.public_key}")
+ assert response.status_code == 200
+ data = response.json()
+ assert data["public_key"] == sample_node.public_key
+ assert data["name"] == sample_node.name
+
+ def test_get_node_not_found(self, client_no_auth):
+ """Test getting a non-existent node."""
+ response = client_no_auth.get("/api/v1/nodes/nonexistent123")
+ assert response.status_code == 404
+
+
+class TestNodeTags:
+ """Tests for node tag endpoints."""
+
+ def test_create_node_tag(self, client_no_auth, sample_node):
+ """Test creating a node tag."""
+ response = client_no_auth.post(
+ f"/api/v1/nodes/{sample_node.public_key}/tags",
+ json={"key": "location", "value": "building-a"},
+ )
+ assert response.status_code == 201 # Created
+ data = response.json()
+ assert data["key"] == "location"
+ assert data["value"] == "building-a"
+
+ def test_get_node_tag(self, client_no_auth, sample_node, sample_node_tag):
+ """Test getting a specific node tag."""
+ response = client_no_auth.get(
+ f"/api/v1/nodes/{sample_node.public_key}/tags/{sample_node_tag.key}"
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert data["key"] == sample_node_tag.key
+ assert data["value"] == sample_node_tag.value
+
+ def test_update_node_tag(self, client_no_auth, sample_node, sample_node_tag):
+ """Test updating a node tag."""
+ response = client_no_auth.put(
+ f"/api/v1/nodes/{sample_node.public_key}/tags/{sample_node_tag.key}",
+ json={"value": "staging"},
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert data["value"] == "staging"
+
+ def test_delete_node_tag(self, client_no_auth, sample_node, sample_node_tag):
+ """Test deleting a node tag."""
+ response = client_no_auth.delete(
+ f"/api/v1/nodes/{sample_node.public_key}/tags/{sample_node_tag.key}"
+ )
+ assert response.status_code == 204 # No Content
+
+ # Verify it's deleted
+ response = client_no_auth.get(
+ f"/api/v1/nodes/{sample_node.public_key}/tags/{sample_node_tag.key}"
+ )
+ assert response.status_code == 404
+
+ def test_tag_crud_requires_admin(self, client_with_auth, sample_node):
+ """Test that tag CRUD operations require admin auth."""
+ # Without auth
+ response = client_with_auth.post(
+ f"/api/v1/nodes/{sample_node.public_key}/tags",
+ json={"key": "test", "value": "test"},
+ )
+ assert response.status_code == 401
+
+ # With read key (not admin)
+ response = client_with_auth.post(
+ f"/api/v1/nodes/{sample_node.public_key}/tags",
+ json={"key": "test", "value": "test"},
+ headers={"Authorization": "Bearer test-read-key"},
+ )
+ assert response.status_code == 403
+
+ # With admin key
+ response = client_with_auth.post(
+ f"/api/v1/nodes/{sample_node.public_key}/tags",
+ json={"key": "test", "value": "test"},
+ headers={"Authorization": "Bearer test-admin-key"},
+ )
+ assert response.status_code == 201 # Created
diff --git a/tests/test_api/test_telemetry.py b/tests/test_api/test_telemetry.py
new file mode 100644
index 0000000..115e7ad
--- /dev/null
+++ b/tests/test_api/test_telemetry.py
@@ -0,0 +1,58 @@
+"""Tests for telemetry API routes."""
+
+import pytest
+
+
+class TestListTelemetry:
+ """Tests for GET /telemetry endpoint."""
+
+ def test_list_telemetry_empty(self, client_no_auth):
+ """Test listing telemetry when database is empty."""
+ response = client_no_auth.get("/api/v1/telemetry")
+ assert response.status_code == 200
+ data = response.json()
+ assert data["items"] == []
+ assert data["total"] == 0
+
+ def test_list_telemetry_with_data(self, client_no_auth, sample_telemetry):
+ """Test listing telemetry with data in database."""
+ response = client_no_auth.get("/api/v1/telemetry")
+ assert response.status_code == 200
+ data = response.json()
+ assert len(data["items"]) == 1
+ assert data["total"] == 1
+ assert data["items"][0]["node_public_key"] == sample_telemetry.node_public_key
+ assert data["items"][0]["parsed_data"] == sample_telemetry.parsed_data
+
+ def test_list_telemetry_filter_by_node(self, client_no_auth, sample_telemetry):
+ """Test filtering telemetry by node public key."""
+ response = client_no_auth.get(
+ f"/api/v1/telemetry?node_public_key={sample_telemetry.node_public_key}"
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert len(data["items"]) == 1
+
+ response = client_no_auth.get(
+ "/api/v1/telemetry?node_public_key=nonexistent"
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert len(data["items"]) == 0
+
+
+class TestGetTelemetry:
+ """Tests for GET /telemetry/{id} endpoint."""
+
+ def test_get_telemetry_success(self, client_no_auth, sample_telemetry):
+ """Test getting a specific telemetry record."""
+ response = client_no_auth.get(f"/api/v1/telemetry/{sample_telemetry.id}")
+ assert response.status_code == 200
+ data = response.json()
+ assert data["id"] == sample_telemetry.id
+ assert data["node_public_key"] == sample_telemetry.node_public_key
+
+ def test_get_telemetry_not_found(self, client_no_auth):
+ """Test getting a non-existent telemetry record."""
+ response = client_no_auth.get("/api/v1/telemetry/nonexistent-id")
+ assert response.status_code == 404
diff --git a/tests/test_api/test_trace_paths.py b/tests/test_api/test_trace_paths.py
new file mode 100644
index 0000000..7ceaa2e
--- /dev/null
+++ b/tests/test_api/test_trace_paths.py
@@ -0,0 +1,42 @@
+"""Tests for trace path API routes."""
+
+import pytest
+
+
+class TestListTracePaths:
+ """Tests for GET /trace-paths endpoint."""
+
+ def test_list_trace_paths_empty(self, client_no_auth):
+ """Test listing trace paths when database is empty."""
+ response = client_no_auth.get("/api/v1/trace-paths")
+ assert response.status_code == 200
+ data = response.json()
+ assert data["items"] == []
+ assert data["total"] == 0
+
+ def test_list_trace_paths_with_data(self, client_no_auth, sample_trace_path):
+ """Test listing trace paths with data in database."""
+ response = client_no_auth.get("/api/v1/trace-paths")
+ assert response.status_code == 200
+ data = response.json()
+ assert len(data["items"]) == 1
+ assert data["total"] == 1
+ assert data["items"][0]["path_hashes"] == sample_trace_path.path_hashes
+ assert data["items"][0]["hop_count"] == sample_trace_path.hop_count
+
+
+class TestGetTracePath:
+ """Tests for GET /trace-paths/{id} endpoint."""
+
+ def test_get_trace_path_success(self, client_no_auth, sample_trace_path):
+ """Test getting a specific trace path."""
+ response = client_no_auth.get(f"/api/v1/trace-paths/{sample_trace_path.id}")
+ assert response.status_code == 200
+ data = response.json()
+ assert data["id"] == sample_trace_path.id
+ assert data["path_hashes"] == sample_trace_path.path_hashes
+
+ def test_get_trace_path_not_found(self, client_no_auth):
+ """Test getting a non-existent trace path."""
+ response = client_no_auth.get("/api/v1/trace-paths/nonexistent-id")
+ assert response.status_code == 404