test: add test suite - unit tests, webhook endpoint tests, and CI workflow
57 unit tests covering clean helpers, API signing, and field mapping fixes (FIX-E06, FIX-M16, BUG-01, BUG-03); integration tests for webhook endpoints with mocked DB; Forgejo CI workflow with TimescaleDB service container. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
2ca3d2f021
commit
6ed4d3a1e2
10 changed files with 614 additions and 0 deletions
40
.forgejo/workflows/ci-tests.yml
Normal file
40
.forgejo/workflows/ci-tests.yml
Normal file
|
|
@ -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: ""
|
||||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
0
tests/fixtures/__init__.py
vendored
Normal file
0
tests/fixtures/__init__.py
vendored
Normal file
109
tests/fixtures/api_responses.py
vendored
Normal file
109
tests/fixtures/api_responses.py
vendored
Normal file
|
|
@ -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",
|
||||||
|
}
|
||||||
0
tests/integration/__init__.py
Normal file
0
tests/integration/__init__.py
Normal file
130
tests/integration/test_webhook_endpoints.py
Normal file
130
tests/integration/test_webhook_endpoints.py
Normal file
|
|
@ -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
|
||||||
0
tests/unit/__init__.py
Normal file
0
tests/unit/__init__.py
Normal file
60
tests/unit/test_api_signing.py
Normal file
60
tests/unit/test_api_signing.py
Normal file
|
|
@ -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
|
||||||
125
tests/unit/test_clean_helpers.py
Normal file
125
tests/unit/test_clean_helpers.py
Normal file
|
|
@ -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
|
||||||
150
tests/unit/test_field_mapping.py
Normal file
150
tests/unit/test_field_mapping.py
Normal file
|
|
@ -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
|
||||||
Loading…
Reference in a new issue