"""Unit тесты для OSM GPS-источника (ET-008). U-42: split_bbox_for_osm разбивает правильно U-43: длина через Haversine U-44: защита от XXE через defusedxml """ import os import pytest from src.api.gps_tracks.sources.osm import ( OsmParser, split_bbox_for_osm, _haversine_m, _parse_gpx_trackpoints, ) # ─── U-42: split_bbox_for_osm ──────────────────────────────────────────────── def test_u42_split_bbox_basic(): """U-42: корректное разбиение на ячейки.""" bbox = (37.0, 55.0, 38.0, 56.0) # 1° x 1° cells = split_bbox_for_osm(bbox, cell_size=0.25) # 1° / 0.25° = 4 ячейки по каждой оси = 16 ячеек assert len(cells) == 16 def test_u42_split_bbox_cell_size(): """U-42: каждая ячейка не больше cell_size по размеру.""" bbox = (29.0, 49.5, 47.5, 60.0) # ЦФО cells = split_bbox_for_osm(bbox, cell_size=0.25) for cell in cells: west, south, east, north = cell assert east - west <= 0.25 + 1e-9 assert north - south <= 0.25 + 1e-9 def test_u42_split_bbox_covers_region(): """U-42: все ячейки вместе покрывают весь регион.""" bbox = (37.0, 55.0, 38.0, 56.0) cells = split_bbox_for_osm(bbox, cell_size=0.25) min_lon = min(c[0] for c in cells) min_lat = min(c[1] for c in cells) max_lon = max(c[2] for c in cells) max_lat = max(c[3] for c in cells) assert abs(min_lon - 37.0) < 1e-9 assert abs(min_lat - 55.0) < 1e-9 assert abs(max_lon - 38.0) < 0.25 + 1e-9 # последняя ячейка обрезается assert abs(max_lat - 56.0) < 0.25 + 1e-9 def test_u42_split_small_bbox(): """U-42: bbox меньше cell_size даёт одну ячейку.""" bbox = (37.0, 55.0, 37.1, 55.1) cells = split_bbox_for_osm(bbox, cell_size=0.25) assert len(cells) == 1 def test_u42_split_bbox_no_overlap(): """U-42: ячейки не перекрываются (west следующей = east предыдущей).""" bbox = (37.0, 55.0, 37.5, 55.25) cells = split_bbox_for_osm(bbox, cell_size=0.25) # При bbox шириной 0.5° и cell_size=0.25 должно быть 2 ячейки по оси lon assert len(cells) == 2 # Восток первой ячейки = запад второй cells_sorted = sorted(cells, key=lambda c: c[0]) assert abs(cells_sorted[0][2] - cells_sorted[1][0]) < 1e-9 # ─── U-43: Haversine длина ─────────────────────────────────────────────────── def test_u43_haversine_known_distance(): """U-43: проверка haversine на известном расстоянии.""" # Москва (37.617, 55.755) → Химки (37.425, 55.889) ≈ 20 км dist = _haversine_m(37.617, 55.755, 37.425, 55.889) assert 18000 < dist < 22000 def test_u43_haversine_zero_distance(): """U-43: одна точка → расстояние 0.""" dist = _haversine_m(37.617, 55.755, 37.617, 55.755) assert dist == pytest.approx(0.0, abs=1e-6) def test_u43_haversine_symmetry(): """U-43: расстояние A→B = B→A.""" d1 = _haversine_m(37.617, 55.755, 37.425, 55.889) d2 = _haversine_m(37.425, 55.889, 37.617, 55.755) assert abs(d1 - d2) < 1e-6 def test_u43_haversine_short_distance(): """U-43: короткое расстояние (~111 м на экваторе при 0.001° по lon).""" dist = _haversine_m(0.0, 0.0, 0.001, 0.0) assert 100 < dist < 120 # ─── U-44: защита от XXE ───────────────────────────────────────────────────── def test_u44_xxe_protection(): """U-44: defusedxml блокирует XXE атаку.""" fixture_path = os.path.join( os.path.dirname(__file__), "../../tests/fixtures/gps-tracks/xxe-payload.gpx", ) with open(fixture_path, "rb") as f: content = f.read() # Должен либо выбросить исключение, либо вернуть пустой список без чтения /etc/passwd try: tracks = _parse_gpx_trackpoints(content, "osm", "") # Если парсинг прошёл без ошибки — проверяем что /etc/passwd не попал в данные for track in tracks: assert "root:" not in str(track) assert "/bin/" not in str(track) except Exception: # defusedxml выбросил исключение — это ожидаемое поведение pass def test_u44_valid_gpx_parsed_correctly(): """U-44: корректный GPX с gpx_id парсится правильно.""" fixture_path = os.path.join( os.path.dirname(__file__), "../../tests/fixtures/gps-tracks/osm-trackpoints-bbox-moscow.gpx", ) with open(fixture_path, "rb") as f: content = f.read() tracks = _parse_gpx_trackpoints(content, "osm", "") assert len(tracks) == 1 track = tracks[0] assert track.points_count == 3 assert abs(track.min_lat - 55.751) < 0.001 assert abs(track.max_lat - 55.753) < 0.001 assert track.source_id == "osm" def test_u44_anonymous_trackpoints_skipped(): """U-44: анонимные точки без gpx_id пропускаются.""" gpx_without_ids = b""" """ tracks = _parse_gpx_trackpoints(gpx_without_ids, "osm", "") assert len(tracks) == 0 def test_u44_multiple_tracks_in_gpx(): """U-44: несколько gpx_id в одном ответе парсятся как разные треки.""" gpx_multi = b""" """ tracks = _parse_gpx_trackpoints(gpx_multi, "osm", "") assert len(tracks) == 2 ids = {t.external_id for t in tracks} assert "111" in ids assert "222" in ids # ─── U-45: _parse_gpx_meta_response ────────────────────────────────────────── def test_u45_meta_response_with_known_tag(): """U-45: _parse_gpx_meta_response extracts activity via MAPPING.""" from src.api.gps_tracks.sources.osm import _parse_gpx_meta_response content = b""" Weekend ride enduro motorcycle """ meta = _parse_gpx_meta_response(content) assert meta is not None assert meta["activity_type"] == "enduro" assert meta["name"] == "my_ride.gpx" assert meta["user"] == "alice" assert meta["description"] == "Weekend ride" def test_u45_meta_response_unknown_tag_returns_none_activity(): """U-45: unknown tag → activity_type is None.""" from src.api.gps_tracks.sources.osm import _parse_gpx_meta_response content = b""" unknown-sport """ meta = _parse_gpx_meta_response(content) assert meta is not None assert meta["activity_type"] is None def test_u45_meta_response_motorcycle_maps_to_moto(): """U-45: 'motorcycle' tag maps to 'moto' via MAPPING.""" from src.api.gps_tracks.sources.osm import _parse_gpx_meta_response content = b""" motorcycle """ meta = _parse_gpx_meta_response(content) assert meta["activity_type"] == "moto" def test_u45_meta_response_invalid_xml_returns_none(): """U-45: malformed XML returns None.""" from src.api.gps_tracks.sources.osm import _parse_gpx_meta_response meta = _parse_gpx_meta_response(b"not xml at all <<<") assert meta is None def test_u45_meta_response_no_gpx_file_element(): """U-45: valid XML but no gpx_file element → result has all None values.""" from src.api.gps_tracks.sources.osm import _parse_gpx_meta_response content = b"""""" meta = _parse_gpx_meta_response(content) # Function should return the dict with None values, not None itself assert meta is not None assert meta["activity_type"] is None assert meta["name"] is None