diff --git a/.forgejo/workflows/ci-tests.yml b/.forgejo/workflows/ci-tests.yml new file mode 100644 index 0000000..6f49dba --- /dev/null +++ b/.forgejo/workflows/ci-tests.yml @@ -0,0 +1,40 @@ +name: Tests + +on: [push, pull_request] + +jobs: + test: + runs-on: self-hosted + services: + timescaledb: + image: timescale/timescaledb-ha:pg16-ts2.15 + env: + POSTGRES_PASSWORD: test + POSTGRES_DB: tracksolid_test + POSTGRES_USER: postgres + ports: + - 5433:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - uses: actions/checkout@v4 + + - name: Install dependencies + run: | + pip install pytest pytest-asyncio httpx psycopg2-binary requests \ + fastapi uvicorn python-multipart + + - name: Run tests + run: pytest tests/ -v --tb=short + env: + TRACKSOLID_APP_KEY: test_key + TRACKSOLID_APP_SECRET: test_secret + TRACKSOLID_USER_ID: test_user + TRACKSOLID_PWD_MD5: test_md5 + DATABASE_URL: postgresql://postgres:test@localhost:5433/tracksolid_test + TEST_DATABASE_URL: postgresql://postgres:test@localhost:5433/tracksolid_test + JIMI_WEBHOOK_TOKEN: "" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/__init__.py b/tests/fixtures/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/api_responses.py b/tests/fixtures/api_responses.py new file mode 100644 index 0000000..edebe26 --- /dev/null +++ b/tests/fixtures/api_responses.py @@ -0,0 +1,109 @@ +"""Mock Tracksolid Pro API responses for testing.""" + +# jimi.user.device.location.list response +LIVE_POSITIONS_RESPONSE = { + "code": 0, + "result": [ + { + "imei": "123456789012345", + "lat": -1.2921, + "lng": 36.8219, + "speed": 45.5, + "direction": 180, + "gpsTime": "2024-04-12 08:00:00", + "hbTime": "2024-04-12 08:00:05", + "accStatus": "1", + "gpsSignal": 4, + "gpsNum": 8, + "currentMileage": 1234.5, + "posType": "GPS", + "confidence": 95, + "status": "1", + "locDesc": "Nairobi CBD", + }, + { + # Zero Island — should be filtered by is_valid_fix + "imei": "999999999999999", + "lat": 0.0, + "lng": 0.0, + "speed": 0, + "gpsTime": "2024-04-12 08:00:00", + }, + ] +} + +# jimi.device.track.mileage response (distance in METRES — FIX-M16) +TRIPS_RESPONSE = { + "code": 0, + "result": [ + { + "imei": "123456789012345", + "startTime": "2024-04-12 07:00:00", + "endTime": "2024-04-12 08:00:00", + "distance": 15000, # 15000 METRES = 15.0 km + "avgSpeed": 15.0, + "maxSpeed": 60.0, + "runTimeSecond": 3600, + } + ] +} + +# jimi.device.alarm.list response (FIX-E06: uses alertTypeId, not alarmType) +ALARMS_RESPONSE = { + "code": 0, + "result": [ + { + "imei": "123456789012345", + "alertTypeId": "4", # poll field name + "alarmTypeName": "Speeding", # poll field name + "alertTime": "2024-04-12 07:30:00", # poll field name + "lat": -1.2921, + "lng": 36.8219, + "speed": 95.0, + "accStatus": "1", + } + ] +} + +# Webhook /pushalarm payload (uses alarmType, not alertTypeId) +WEBHOOK_ALARM_PAYLOAD = { + "deviceImei": "123456789012345", + "alarmType": "4", + "alarmName": "Speeding", + "gateTime": "2024-04-12 07:30:00", + "lat": -1.2921, + "lng": 36.8219, + "speed": 95.0, +} + +# Webhook /pushtripreport payload (BCD timestamp — BUG-03) +WEBHOOK_TRIP_BCD_PAYLOAD = { + "deviceImei": "123456789012345", + "beginTime": "220415103000", # BCD YYMMDDHHmmss = 2022-04-15 10:30:00 + "endTime": "220415113000", # BCD YYMMDDHHmmss = 2022-04-15 11:30:00 + "miles": 12.5, + "beginLat": -1.2921, + "beginLng": 36.8219, + "endLat": -1.3000, + "endLng": 36.8300, +} + +WEBHOOK_TRIP_ISO_PAYLOAD = { + "deviceImei": "123456789012345", + "beginTime": "2024-04-12 07:00:00", + "endTime": "2024-04-12 08:00:00", + "miles": 15.5, +} + +# Webhook /pushobd payload +WEBHOOK_OBD_PAYLOAD = { + "deviceImei": "123456789012345", + "obdJson": '{"event_time": 1712908800, "AccState": 1, "statusFlags": 0, "lat": -1.2921, "lng": 36.8219}', +} + +# Alarm with NULL alarm_type (BUG-02 guard) +WEBHOOK_ALARM_NULL_TYPE = { + "deviceImei": "123456789012345", + "alarmType": None, + "gateTime": "2024-04-12 07:30:00", +} diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/test_webhook_endpoints.py b/tests/integration/test_webhook_endpoints.py new file mode 100644 index 0000000..2ca474a --- /dev/null +++ b/tests/integration/test_webhook_endpoints.py @@ -0,0 +1,130 @@ +"""Integration tests for FastAPI webhook endpoints.""" +import sys +import os +import json +import pytest +from unittest.mock import MagicMock, patch, call +from contextlib import contextmanager + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(__file__)))) + +os.environ.setdefault("TRACKSOLID_APP_KEY", "test_key") +os.environ.setdefault("TRACKSOLID_APP_SECRET", "test_secret") +os.environ.setdefault("TRACKSOLID_USER_ID", "test_user") +os.environ.setdefault("TRACKSOLID_PWD_MD5", "test_md5") +os.environ.setdefault("DATABASE_URL", "postgresql://test:test@localhost:5432/test") +os.environ.setdefault("JIMI_WEBHOOK_TOKEN", "") + +from fastapi.testclient import TestClient +import webhook_receiver_rev +from tests.fixtures.api_responses import ( + WEBHOOK_ALARM_PAYLOAD, + WEBHOOK_ALARM_NULL_TYPE, + WEBHOOK_TRIP_BCD_PAYLOAD, + WEBHOOK_TRIP_ISO_PAYLOAD, + WEBHOOK_OBD_PAYLOAD, +) + + +def make_mock_conn(): + """Create a mock DB connection with cursor support.""" + mock_cur = MagicMock() + mock_conn = MagicMock() + mock_conn.cursor.return_value.__enter__ = lambda s: mock_cur + mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=False) + return mock_conn, mock_cur + + +@contextmanager +def mock_get_conn_ctx(mock_conn): + yield mock_conn + + +@pytest.fixture +def client(): + return TestClient(webhook_receiver_rev.app, raise_server_exceptions=True) + + +@pytest.fixture +def mock_db(): + mock_conn, mock_cur = make_mock_conn() + with patch("webhook_receiver_rev.get_conn") as mock_get_conn: + mock_get_conn.return_value = mock_get_conn_ctx(mock_conn) + yield mock_conn, mock_cur + + +class TestHealth: + def test_health_returns_ok(self, client): + response = client.get("/health") + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + + +class TestPushAlarm: + def test_valid_alarm_accepted(self, client, mock_db): + mock_conn, mock_cur = mock_db + data_list = json.dumps([WEBHOOK_ALARM_PAYLOAD]) + response = client.post("/pushalarm", data={"token": "", "data_list": data_list}) + assert response.status_code == 200 + assert response.json()["code"] == 0 + + def test_null_alarm_type_skipped(self, client, mock_db): + """BUG-02 guard: NULL alarm_type must be rejected, not inserted.""" + mock_conn, mock_cur = mock_db + data_list = json.dumps([WEBHOOK_ALARM_NULL_TYPE]) + response = client.post("/pushalarm", data={"token": "", "data_list": data_list}) + assert response.status_code == 200 + # Verify no INSERT was executed (only SAVEPOINT + RELEASE calls) + insert_calls = [c for c in mock_cur.execute.call_args_list + if "INSERT" in str(c)] + assert len(insert_calls) == 0, "NULL alarm_type must not be inserted" + + def test_empty_data_list_ok(self, client): + response = client.post("/pushalarm", data={"token": "", "data_list": ""}) + assert response.status_code == 200 + + def test_batch_with_bad_item_processes_rest(self, client, mock_db): + """BUG-04: One bad item must not abort the entire batch.""" + mock_conn, mock_cur = mock_db + # One valid, one missing alarm_type (will be skipped, not crash) + items = [WEBHOOK_ALARM_PAYLOAD, WEBHOOK_ALARM_NULL_TYPE] + data_list = json.dumps(items) + response = client.post("/pushalarm", data={"token": "", "data_list": data_list}) + assert response.status_code == 200 + assert response.json()["code"] == 0 + + +class TestPushTripReport: + def test_bcd_timestamp_parsed(self, client, mock_db): + """BUG-03: BCD timestamp 220415103000 must be parsed correctly.""" + mock_conn, mock_cur = mock_db + data_list = json.dumps([WEBHOOK_TRIP_BCD_PAYLOAD]) + response = client.post("/pushtripreport", data={"token": "", "data_list": data_list}) + assert response.status_code == 200 + assert response.json()["code"] == 0 + # Verify an INSERT was attempted + insert_calls = [c for c in mock_cur.execute.call_args_list + if "INSERT" in str(c)] + assert len(insert_calls) > 0, "Trip with BCD timestamp must trigger INSERT" + + def test_iso_timestamp_accepted(self, client, mock_db): + mock_conn, mock_cur = mock_db + data_list = json.dumps([WEBHOOK_TRIP_ISO_PAYLOAD]) + response = client.post("/pushtripreport", data={"token": "", "data_list": data_list}) + assert response.status_code == 200 + + def test_missing_imei_skipped(self, client, mock_db): + mock_conn, mock_cur = mock_db + bad_trip = {"beginTime": "2024-04-12 07:00:00", "miles": 10.0} + data_list = json.dumps([bad_trip]) + response = client.post("/pushtripreport", data={"token": "", "data_list": data_list}) + assert response.status_code == 200 + + +class TestPushObd: + def test_valid_obd_accepted(self, client, mock_db): + mock_conn, mock_cur = mock_db + data_list = json.dumps([WEBHOOK_OBD_PAYLOAD]) + response = client.post("/pushobd", data={"token": "", "data_list": data_list}) + assert response.status_code == 200 + assert response.json()["code"] == 0 diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/test_api_signing.py b/tests/unit/test_api_signing.py new file mode 100644 index 0000000..946b0c3 --- /dev/null +++ b/tests/unit/test_api_signing.py @@ -0,0 +1,60 @@ +"""Unit tests for Tracksolid API MD5 signature generation.""" +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(__file__)))) + +os.environ.setdefault("TRACKSOLID_APP_KEY", "test_key") +os.environ.setdefault("TRACKSOLID_APP_SECRET", "test_secret") +os.environ.setdefault("TRACKSOLID_USER_ID", "test_user") +os.environ.setdefault("TRACKSOLID_PWD_MD5", "test_md5") +os.environ.setdefault("DATABASE_URL", "postgresql://test:test@localhost:5432/test") + +from ts_shared_rev import build_sign + + +class TestBuildSign: + def test_basic_signature(self): + """Known input + secret produces expected MD5.""" + params = {"method": "jimi.test", "app_key": "mykey", "v": "1.0"} + secret = "mysecret" + result = build_sign(params, secret) + # Verify it's a 32-char uppercase hex string + assert len(result) == 32 + assert result == result.upper() + assert all(c in "0123456789ABCDEF" for c in result) + + def test_sign_key_excluded(self): + """The 'sign' key itself must be excluded from signing.""" + params_with = {"method": "test", "sign": "old_sign", "v": "1.0"} + params_without = {"method": "test", "v": "1.0"} + secret = "secret" + assert build_sign(params_with, secret) == build_sign(params_without, secret) + + def test_none_values_excluded(self): + """Keys with None values are excluded from signing.""" + params_with_none = {"method": "test", "optional": None, "v": "1.0"} + params_without_none = {"method": "test", "v": "1.0"} + secret = "secret" + assert build_sign(params_with_none, secret) == build_sign(params_without_none, secret) + + def test_alphabetical_key_ordering(self): + """Keys are sorted alphabetically for consistent signing.""" + params_abc = {"a": "1", "b": "2", "c": "3"} + params_cba = {"c": "3", "b": "2", "a": "1"} + secret = "secret" + assert build_sign(params_abc, secret) == build_sign(params_cba, secret) + + def test_different_secrets_produce_different_signs(self): + params = {"method": "test"} + assert build_sign(params, "secret1") != build_sign(params, "secret2") + + def test_known_hash(self): + """Verify against a manually computed hash.""" + import hashlib + params = {"app_key": "ABC", "method": "test", "v": "1.0"} + secret = "XYZ" + sorted_keys = sorted(params.keys()) + raw = secret + "".join(f"{k}{params[k]}" for k in sorted_keys) + secret + expected = hashlib.md5(raw.encode("utf-8")).hexdigest().upper() + assert build_sign(params, secret) == expected diff --git a/tests/unit/test_clean_helpers.py b/tests/unit/test_clean_helpers.py new file mode 100644 index 0000000..811f568 --- /dev/null +++ b/tests/unit/test_clean_helpers.py @@ -0,0 +1,125 @@ +"""Unit tests for ts_shared_rev data cleaning helpers.""" +import sys +import os +import pytest + +# Add parent directory to path so we can import ts_shared_rev +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(__file__)))) + +# Set required env vars before import +os.environ.setdefault("TRACKSOLID_APP_KEY", "test_key") +os.environ.setdefault("TRACKSOLID_APP_SECRET", "test_secret") +os.environ.setdefault("TRACKSOLID_USER_ID", "test_user") +os.environ.setdefault("TRACKSOLID_PWD_MD5", "test_md5") +os.environ.setdefault("DATABASE_URL", "postgresql://test:test@localhost:5432/test") + +from ts_shared_rev import clean, clean_num, clean_int, clean_ts, is_valid_fix + + +class TestClean: + def test_none_returns_none(self): + assert clean(None) is None + + def test_empty_string_returns_none(self): + assert clean("") is None + + def test_whitespace_only_returns_none(self): + assert clean(" ") is None + + def test_normal_string_preserved(self): + assert clean("hello") == "hello" + + def test_strips_whitespace(self): + assert clean(" hello ") == "hello" + + def test_non_string_converted(self): + assert clean(123) == "123" + + def test_zero_preserved(self): + assert clean(0) == "0" + + +class TestCleanNum: + def test_valid_float_string(self): + assert clean_num("3.14") == pytest.approx(3.14) + + def test_valid_integer_string(self): + assert clean_num("42") == pytest.approx(42.0) + + def test_non_numeric_returns_none(self): + assert clean_num("abc") is None + + def test_none_returns_none(self): + assert clean_num(None) is None + + def test_empty_string_returns_none(self): + assert clean_num("") is None + + def test_numeric_value_passthrough(self): + assert clean_num(45.5) == pytest.approx(45.5) + + def test_negative_value(self): + assert clean_num("-1.5") == pytest.approx(-1.5) + + +class TestCleanInt: + def test_integer_string(self): + assert clean_int("42") == 42 + + def test_float_string_truncates(self): + assert clean_int("3.9") == 3 + + def test_non_numeric_returns_none(self): + assert clean_int("abc") is None + + def test_none_returns_none(self): + assert clean_int(None) is None + + +class TestCleanTs: + def test_valid_iso_timestamp(self): + result = clean_ts("2024-04-12 08:00:00") + assert result == "2024-04-12 08:00:00" + + def test_valid_iso_with_timezone(self): + result = clean_ts("2024-04-12T08:00:00Z") + assert result is not None + + def test_garbage_returns_none(self): + assert clean_ts("not-a-date") is None + + def test_none_returns_none(self): + assert clean_ts(None) is None + + def test_empty_string_returns_none(self): + assert clean_ts("") is None + + def test_bcd_format_returns_none(self): + # BCD format YYMMDDHHmmss is NOT handled by clean_ts (only by _parse_trip_ts) + assert clean_ts("220415103000") is None + + +class TestIsValidFix: + def test_zero_island_filtered(self): + assert is_valid_fix(0.0, 0.0) is False + + def test_valid_nairobi_coords(self): + assert is_valid_fix(-1.2921, 36.8219) is True + + def test_none_lat_returns_false(self): + assert is_valid_fix(None, 36.8219) is False + + def test_none_lng_returns_false(self): + assert is_valid_fix(-1.2921, None) is False + + def test_out_of_range_lat(self): + assert is_valid_fix(91.0, 36.8219) is False + + def test_out_of_range_lng(self): + assert is_valid_fix(-1.2921, 181.0) is False + + def test_valid_extreme_coords(self): + assert is_valid_fix(90.0, 180.0) is True + + def test_string_coords_accepted(self): + assert is_valid_fix("-1.2921", "36.8219") is True diff --git a/tests/unit/test_field_mapping.py b/tests/unit/test_field_mapping.py new file mode 100644 index 0000000..e9c6b04 --- /dev/null +++ b/tests/unit/test_field_mapping.py @@ -0,0 +1,150 @@ +"""Unit tests locking in known field mapping fixes (FIX-E06, FIX-M16, BUG-03).""" +import sys +import os +import pytest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(__file__)))) + +os.environ.setdefault("TRACKSOLID_APP_KEY", "test_key") +os.environ.setdefault("TRACKSOLID_APP_SECRET", "test_secret") +os.environ.setdefault("TRACKSOLID_USER_ID", "test_user") +os.environ.setdefault("TRACKSOLID_PWD_MD5", "test_md5") +os.environ.setdefault("DATABASE_URL", "postgresql://test:test@localhost:5432/test") + +from ts_shared_rev import clean, clean_ts, clean_num +from webhook_receiver_rev import _parse_trip_ts, unix_to_ts + + +class TestFIXE06AlarmFieldMapping: + """FIX-E06: Poll alarm endpoint uses alertTypeId/alarmTypeName/alertTime.""" + + def test_poll_uses_alert_type_id(self): + """Alarm poll response must use alertTypeId, not alarmType.""" + api_alarm = { + "imei": "123456789012345", + "alertTypeId": "4", # CORRECT poll field + "alarmType": "WRONG_FIELD", # webhook field - should NOT be used for polls + "alarmTypeName": "Speeding", + "alertTime": "2024-04-12 07:30:00", + } + # FIX-E06: extract using alertTypeId (poll field name) + alarm_type = clean(api_alarm.get("alertTypeId")) + assert alarm_type == "4", "Must use alertTypeId not alarmType for poll responses" + + def test_poll_uses_alarm_type_name(self): + """Alarm name must come from alarmTypeName, not alarmName.""" + api_alarm = { + "alertTypeId": "4", + "alarmTypeName": "Speeding", # CORRECT poll field + "alarmName": "WRONG_FIELD", # webhook field + "alertTime": "2024-04-12 07:30:00", + } + alarm_name = clean(api_alarm.get("alarmTypeName")) + assert alarm_name == "Speeding" + + def test_poll_uses_alert_time(self): + """Alarm time must come from alertTime, not alarmTime.""" + api_alarm = { + "alertTypeId": "4", + "alarmTypeName": "Speeding", + "alertTime": "2024-04-12 07:30:00", # CORRECT poll field + "alarmTime": "WRONG_FIELD", # webhook field + } + alarm_time = clean_ts(api_alarm.get("alertTime")) + assert alarm_time == "2024-04-12 07:30:00" + + def test_wrong_field_names_return_none(self): + """Using incorrect webhook field names on poll data returns None (the bug).""" + api_alarm = {"alertTypeId": "4", "alarmTypeName": "Speeding", "alertTime": "2024-04-12 07:30:00"} + # These are webhook fields — should NOT be present in poll responses + assert clean(api_alarm.get("alarmType")) is None + assert clean(api_alarm.get("alarmName")) is None + assert clean_ts(api_alarm.get("alarmTime")) is None + + +class TestFIXM16DistanceUnits: + """FIX-M16: Trip distance arrives in METRES from API, must be stored as km.""" + + def test_metres_divided_by_1000(self): + """15000 metres from API → 15.0 km stored.""" + raw_dist_metres = 15000 + dist_km = round(raw_dist_metres / 1000.0, 4) + assert dist_km == pytest.approx(15.0) + + def test_small_distance(self): + """500 metres → 0.5 km.""" + assert round(500 / 1000.0, 4) == pytest.approx(0.5) + + def test_none_distance(self): + """None distance stays None (no division by zero).""" + raw_dist = clean_num(None) + dist_km = round(raw_dist / 1000.0, 4) if raw_dist is not None else None + assert dist_km is None + + def test_zero_distance(self): + """0 metres → 0.0 km.""" + raw_dist = clean_num(0) + dist_km = round(raw_dist / 1000.0, 4) if raw_dist is not None else None + assert dist_km == pytest.approx(0.0) + + def test_non_divided_would_be_wrong(self): + """Verify that NOT dividing produces obviously wrong km values.""" + raw_dist_metres = 15000 + # Without fix: storing raw value as km + wrong_km = raw_dist_metres + # With fix: correct km + correct_km = raw_dist_metres / 1000.0 + assert wrong_km == 15000 # Would mean 15,000 km trip — clearly wrong + assert correct_km == 15.0 + + +class TestBUG03TripTimestamps: + """BUG-03: Trip timestamps may be BCD format YYMMDDHHmmss or ISO string.""" + + def test_bcd_12_char_format(self): + """220415103000 → 2022-04-15 10:30:00.""" + result = _parse_trip_ts("220415103000") + assert result == "2022-04-15 10:30:00" + + def test_bcd_14_char_format(self): + """20220415103000 → 2022-04-15 10:30:00.""" + result = _parse_trip_ts("20220415103000") + assert result == "2022-04-15 10:30:00" + + def test_iso_string_passthrough(self): + """ISO string passes through unchanged.""" + result = _parse_trip_ts("2024-04-12 08:00:00") + assert result == "2024-04-12 08:00:00" + + def test_none_returns_none(self): + assert _parse_trip_ts(None) is None + + def test_garbage_returns_none(self): + assert _parse_trip_ts("not-a-timestamp") is None + + def test_bcd_year_20xx(self): + """24 prefix → 2024-xx-xx.""" + result = _parse_trip_ts("240412080000") + assert result is not None + assert result.startswith("2024-04-12") + + +class TestUnixToTs: + """BUG-01: OBD event_time may be Unix epoch (seconds or milliseconds).""" + + def test_unix_seconds(self): + result = unix_to_ts(1712908800) + assert result is not None + assert "2024" in result + + def test_unix_milliseconds(self): + result = unix_to_ts(1712908800000) # ms — should be divided by 1000 + assert result is not None + assert "2024" in result + + def test_unix_seconds_matches_milliseconds(self): + """Seconds and milliseconds of same moment produce same result.""" + assert unix_to_ts(1712908800) == unix_to_ts(1712908800000) + + def test_none_returns_none(self): + assert unix_to_ts(None) is None