Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
name: Tests

on:
pull_request:
branches: [main]
push:
branches: [main]

jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12"]

steps:
- uses: actions/checkout@v4

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"

- name: Run tests
run: |
pytest --cov=hubblenetwork --cov-report=xml --cov-report=term-missing -m "not ble and not integration" -v

- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
if: matrix.python-version == '3.12'
with:
file: ./coverage.xml
fail_ci_if_error: false
verbose: true
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ markers = [
dev = [
"pytest>=7",
"pytest-cov",
"pytest-asyncio",
"mypy",
"ruff",
]
1 change: 1 addition & 0 deletions tests/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Test package
90 changes: 90 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
"""Shared fixtures for hubblenetwork tests."""

from __future__ import annotations

import pytest
from unittest.mock import MagicMock, patch
from typing import List

from hubblenetwork.packets import Location, EncryptedPacket, DecryptedPacket
from hubblenetwork.device import Device
from hubblenetwork.cloud import Credentials, Environment


# Sample test data
@pytest.fixture
def sample_location() -> Location:
"""A sample Location with real coordinates."""
return Location(lat=37.7749, lon=-122.4194, alt_m=10.0, fake=False)


@pytest.fixture
def fake_location() -> Location:
"""A fake Location (used when location is unknown)."""
return Location(lat=90.0, lon=0.0, fake=True)


@pytest.fixture
def sample_encrypted_packet(fake_location) -> EncryptedPacket:
"""A sample encrypted packet with test payload."""
return EncryptedPacket(
timestamp=1700000000,
location=fake_location,
payload=b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f",
rssi=-70,
)


@pytest.fixture
def sample_decrypted_packet(sample_location) -> DecryptedPacket:
"""A sample decrypted packet."""
return DecryptedPacket(
timestamp=1700000000,
device_id="test-device-123",
device_name="Test Device",
location=sample_location,
tags={"env": "test"},
payload=b"Hello, World!",
rssi=-65,
counter=20000,
sequence=42,
)


@pytest.fixture
def sample_device() -> Device:
"""A sample Device object."""
return Device(
id="dev-abc-123",
key=b"\x00" * 32, # 256-bit key
name="Test Device",
tags={"type": "sensor"},
created_ts=1700000000,
active=True,
)


@pytest.fixture
def sample_credentials() -> Credentials:
"""Sample credentials for testing."""
return Credentials(org_id="test-org-id", api_token="test-api-token")


@pytest.fixture
def sample_environment() -> Environment:
"""Sample environment for testing."""
return Environment(name="TEST", url="https://api-test.example.com")


@pytest.fixture
def mock_httpx_client():
"""Mock httpx.Client for testing cloud requests."""
with patch("hubblenetwork.cloud.httpx.Client") as mock_client:
yield mock_client


@pytest.fixture
def mock_bleak_scanner():
"""Mock BleakScanner for testing BLE operations."""
with patch("hubblenetwork.ble.BleakScanner") as mock_scanner:
yield mock_scanner
233 changes: 233 additions & 0 deletions tests/test_ble.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
"""Tests for ble.py BLE scanning functions."""

from __future__ import annotations

import pytest
import asyncio
from unittest.mock import MagicMock, AsyncMock, patch
from datetime import datetime, timezone

from hubblenetwork.ble import (
_TARGET_UUID,
_get_location,
_scan_async,
scan,
scan_async,
_scan_single_async,
scan_single,
scan_single_async,
)
from hubblenetwork.packets import EncryptedPacket, Location


@pytest.mark.ble
class TestGetLocation:
"""Tests for _get_location helper."""

def test_returns_fake_location(self):
"""Test _get_location returns a fake location."""
loc = _get_location()
assert loc is not None
assert isinstance(loc, Location)
assert loc.fake is True
assert loc.lat == 90
assert loc.lon == 0


@pytest.mark.ble
class TestTargetUuid:
"""Tests for target UUID constant."""

def test_target_uuid_format(self):
"""Test target UUID is correct 128-bit Bluetooth format."""
assert _TARGET_UUID == "0000fca6-0000-1000-8000-00805f9b34fb"
assert len(_TARGET_UUID) == 36 # Standard UUID string length


@pytest.mark.ble
class TestScanAsync:
"""Tests for _scan_async function."""

@pytest.mark.asyncio
async def test_scan_returns_empty_on_timeout(self):
"""Test scan returns empty list when no packets found."""
mock_scanner = MagicMock()
mock_scanner.__aenter__ = AsyncMock(return_value=mock_scanner)
mock_scanner.__aexit__ = AsyncMock(return_value=None)

with patch("hubblenetwork.ble.BleakScanner", return_value=mock_scanner):
packets = await _scan_async(0.01) # Very short timeout
assert packets == []

@pytest.mark.asyncio
async def test_scan_collects_matching_packets(self):
"""Test scan collects packets with matching UUID."""
captured_callback = None

def capture_callback(**kwargs):
nonlocal captured_callback
captured_callback = kwargs.get("detection_callback")
mock_scanner = MagicMock()
mock_scanner.__aenter__ = AsyncMock(return_value=mock_scanner)
mock_scanner.__aexit__ = AsyncMock(return_value=None)
return mock_scanner

with patch("hubblenetwork.ble.BleakScanner", side_effect=capture_callback):
# Start scan in background
scan_task = asyncio.create_task(_scan_async(1.0))

# Give scanner time to start
await asyncio.sleep(0.01)

# Simulate device detection
if captured_callback:
mock_device = MagicMock()
mock_adv = MagicMock()
mock_adv.service_data = {_TARGET_UUID: b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09"}
mock_adv.rssi = -65

captured_callback(mock_device, mock_adv)

# Cancel the task (timeout won't complete in time)
await asyncio.sleep(0.01)
scan_task.cancel()

try:
packets = await scan_task
except asyncio.CancelledError:
packets = []

# Note: Due to async timing, packet may or may not be captured
# This test validates the structure works

@pytest.mark.asyncio
async def test_scan_ignores_non_matching_uuid(self):
"""Test scan ignores packets without matching UUID."""
captured_callback = None

def capture_callback(**kwargs):
nonlocal captured_callback
captured_callback = kwargs.get("detection_callback")
mock_scanner = MagicMock()
mock_scanner.__aenter__ = AsyncMock(return_value=mock_scanner)
mock_scanner.__aexit__ = AsyncMock(return_value=None)
return mock_scanner

with patch("hubblenetwork.ble.BleakScanner", side_effect=capture_callback):
scan_task = asyncio.create_task(_scan_async(0.1))
await asyncio.sleep(0.01)

if captured_callback:
mock_device = MagicMock()
mock_adv = MagicMock()
mock_adv.service_data = {"wrong-uuid": b"\x00\x01\x02\x03"}
mock_adv.rssi = -65

captured_callback(mock_device, mock_adv)

packets = await scan_task
assert packets == []


@pytest.mark.ble
class TestScanSingleAsync:
"""Tests for _scan_single_async function."""

@pytest.mark.asyncio
async def test_scan_single_returns_none_on_timeout(self):
"""Test scan_single returns None when no packet found."""
mock_scanner = MagicMock()
mock_scanner.__aenter__ = AsyncMock(return_value=mock_scanner)
mock_scanner.__aexit__ = AsyncMock(return_value=None)

with patch("hubblenetwork.ble.BleakScanner", return_value=mock_scanner):
packet = await _scan_single_async(0.01)
assert packet is None

@pytest.mark.asyncio
async def test_scan_single_returns_first_matching_packet(self):
"""Test scan_single returns first packet with matching UUID."""
captured_callback = None
done_event = asyncio.Event()

def capture_callback(**kwargs):
nonlocal captured_callback
captured_callback = kwargs.get("detection_callback")
mock_scanner = MagicMock()

async def aenter(self):
return self

async def aexit(self, *args):
pass

mock_scanner.__aenter__ = lambda: aenter(mock_scanner)
mock_scanner.__aexit__ = lambda *args: aexit(mock_scanner)
return mock_scanner

with patch("hubblenetwork.ble.BleakScanner", side_effect=capture_callback):
scan_task = asyncio.create_task(_scan_single_async(1.0))
await asyncio.sleep(0.01)

if captured_callback:
mock_device = MagicMock()
mock_adv = MagicMock()
mock_adv.service_data = {_TARGET_UUID: b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09"}
mock_adv.service_uuids = [_TARGET_UUID]
mock_adv.rssi = -70

captured_callback(mock_device, mock_adv)

await asyncio.sleep(0.01)
scan_task.cancel()

try:
await scan_task
except asyncio.CancelledError:
pass


@pytest.mark.ble
class TestSyncWrappers:
"""Tests for synchronous wrapper functions."""

def test_scan_calls_async_version(self):
"""Test scan() calls _scan_async."""
with patch("hubblenetwork.ble._scan_async", new_callable=AsyncMock) as mock_scan:
mock_scan.return_value = []
with patch("hubblenetwork.ble.asyncio.run") as mock_run:
mock_run.return_value = []
result = scan(5.0)
mock_run.assert_called_once()

def test_scan_single_calls_async_version(self):
"""Test scan_single() calls _scan_single_async."""
with patch("hubblenetwork.ble._scan_single_async", new_callable=AsyncMock) as mock_scan:
mock_scan.return_value = None
with patch("hubblenetwork.ble.asyncio.run") as mock_run:
mock_run.return_value = None
result = scan_single(5.0)
mock_run.assert_called_once()


@pytest.mark.ble
class TestAsyncWrappers:
"""Tests for async wrapper functions."""

@pytest.mark.asyncio
async def test_scan_async_wrapper(self):
"""Test scan_async() calls _scan_async."""
with patch("hubblenetwork.ble._scan_async", new_callable=AsyncMock) as mock_scan:
mock_scan.return_value = []
result = await scan_async(5.0)
mock_scan.assert_called_once_with(5.0)
assert result == []

@pytest.mark.asyncio
async def test_scan_single_async_wrapper(self):
"""Test scan_single_async() calls _scan_single_async."""
with patch("hubblenetwork.ble._scan_single_async", new_callable=AsyncMock) as mock_scan:
mock_scan.return_value = None
result = await scan_single_async(5.0)
mock_scan.assert_called_once_with(5.0)
assert result is None
Loading