"""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", "") # Fixtures carry fixed 2024 timestamps; widen the FIX-W04 sanity window so # they stay valid regardless of the wall clock. os.environ.setdefault("WEBHOOK_EVENT_MAX_AGE_DAYS", "100000") from fastapi.testclient import TestClient import webhook_receiver_rev from tests.fixtures.api_responses import ( WEBHOOK_ALARM_PAYLOAD, WEBHOOK_ALARM_NULL_TYPE, WEBHOOK_ALARM_NO_POSITION, WEBHOOK_ALARM_ZERO_ISLAND, 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 data INSERT was executed. log_ingestion always writes one # row to tracksolid.ingestion_log — exclude it from the assertion. data_inserts = [ c for c in mock_cur.execute.call_args_list if "INSERT" in str(c) and "ingestion_log" not in str(c) ] assert len(data_inserts) == 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 def test_alarm_cross_feeds_live_position(self, client, mock_db): """FIX-M21: a valid alarm must additionally upsert live_positions.""" 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 # Exactly one INSERT INTO live_positions should have fired. lp_inserts = [ c for c in mock_cur.execute.call_args_list if "tracksolid.live_positions" in str(c) and "INSERT" in str(c) ] assert len(lp_inserts) == 1, "Cross-feed must upsert live_positions exactly once" def test_alarm_without_lat_lng_skips_cross_feed(self, client, mock_db): """FIX-M21: an alarm without lat/lng must NOT touch live_positions (but must still insert the alarm row).""" mock_conn, mock_cur = mock_db data_list = json.dumps([WEBHOOK_ALARM_NO_POSITION]) response = client.post("/pushalarm", data={"token": "", "data_list": data_list}) assert response.status_code == 200 lp_inserts = [ c for c in mock_cur.execute.call_args_list if "tracksolid.live_positions" in str(c) and "INSERT" in str(c) ] assert len(lp_inserts) == 0, "No live_positions write without a valid fix" alarm_inserts = [ c for c in mock_cur.execute.call_args_list if "tracksolid.alarms" in str(c) and "INSERT" in str(c) ] assert len(alarm_inserts) == 1, "Alarm row must still write" def test_alarm_with_zero_island_skips_cross_feed(self, client, mock_db): """FIX-M21: a (0, 0) fix must NOT propagate to live_positions — is_valid_fix in the shared helper guards Zero Island.""" mock_conn, mock_cur = mock_db data_list = json.dumps([WEBHOOK_ALARM_ZERO_ISLAND]) response = client.post("/pushalarm", data={"token": "", "data_list": data_list}) assert response.status_code == 200 lp_inserts = [ c for c in mock_cur.execute.call_args_list if "tracksolid.live_positions" in str(c) and "INSERT" in str(c) ] assert len(lp_inserts) == 0, "Zero-Island fix must not reach live_positions" 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 class TestJsonPushFormat: """FIX-W02: the documented JSON push format must be parsed, not silently dropped.""" def test_json_data_list_accepted(self, client, mock_db): mock_conn, mock_cur = mock_db response = client.post( "/pushalarm", json={"token": "", "data_list": [WEBHOOK_ALARM_PAYLOAD]}, ) assert response.status_code == 200 assert response.json()["code"] == 0 data_inserts = [ c for c in mock_cur.execute.call_args_list if "tracksolid.alarms" in str(c) and "INSERT" in str(c) ] assert len(data_inserts) == 1, "JSON-body push must insert the alarm" def test_json_single_object_data_accepted(self, client, mock_db): mock_conn, mock_cur = mock_db response = client.post( "/pushalarm", json={"token": "", "data": WEBHOOK_ALARM_PAYLOAD}, ) assert response.status_code == 200 data_inserts = [ c for c in mock_cur.execute.call_args_list if "tracksolid.alarms" in str(c) and "INSERT" in str(c) ] assert len(data_inserts) == 1 def test_malformed_json_returns_empty_success(self, client): response = client.post( "/pushalarm", content=b"{not json", headers={"content-type": "application/json"}, ) assert response.status_code == 200 class TestEventTimestampGuard: """FIX-W04: device-clock garbage (e.g. 2019 alarm_time) must be rejected.""" def test_ancient_alarm_time_rejected(self, client, mock_db): mock_conn, mock_cur = mock_db stale = dict(WEBHOOK_ALARM_PAYLOAD, gateTime="2019-01-01 00:02:17") # Narrow the sanity window (module-level env default is widened for fixtures). from datetime import timedelta with patch.object(webhook_receiver_rev, "_EVENT_MAX_AGE", timedelta(days=30)): response = client.post( "/pushalarm", data={"token": "", "data_list": json.dumps([stale])} ) assert response.status_code == 200 data_inserts = [ c for c in mock_cur.execute.call_args_list if "tracksolid.alarms" in str(c) and "INSERT" in str(c) ] assert len(data_inserts) == 0, "2019 alarm_time must not be inserted" def test_sane_ts_helper(self): from datetime import datetime, timezone, timedelta now = datetime.now(timezone.utc) recent = (now - timedelta(hours=1)).strftime("%Y-%m-%d %H:%M:%S") with patch.object(webhook_receiver_rev, "_EVENT_MAX_AGE", timedelta(days=30)): assert webhook_receiver_rev._is_sane_event_ts(recent) is True assert webhook_receiver_rev._is_sane_event_ts("2019-01-01 00:02:17") is False assert webhook_receiver_rev._is_sane_event_ts(None) is False future = (now + timedelta(days=10)).strftime("%Y-%m-%d %H:%M:%S") assert webhook_receiver_rev._is_sane_event_ts(future) is False