From 5812cbe98ce9f296648fd929def88380c729fa52 Mon Sep 17 00:00:00 2001 From: Didier Moraine Date: Fri, 24 Jul 2026 21:53:40 +0200 Subject: [PATCH] Harden parsing, fix bugs, and add offline + authenticated tests Bug fixes: - Rename duplicate test method names that silently shadowed each other, so the intended assertions actually run. - utils.nowtimestamp_millis: return an int of epoch seconds and drop the deprecated datetime.utcnow() in favour of a timezone-aware now. - Remove the unused "requests" dependency (code uses curl_cffi only). - Drop dead Python 2 code paths (unicode / sys.version_info). Hardening: - process_raw_airline_fleet_data: guard cell indexing and skip malformed rows instead of raising when flightradar24 tweaks its HTML. - login: wrap the request and token extraction, and return a bool. Tests: - New offline unit suite (test_unit.py) with no network access, covering fltr, timestamp helpers, byteify/encode, FR24 state and fleet parsing, get_flight_for_date filtering, weather km conversion, login paths, and the credential loader. Runs as a real CI gate. - New authenticated online suite (test_online_auth.py) that logs in with credentials from FR24_EMAIL/FR24_PASSWORD or a local .env, and asserts a logged-in client sees at least as much history as an anonymous one. Skips cleanly when no credentials are configured. - conftest.py credential loader/fixture. - pytest.ini: narrow python_files to test_*.py and register markers. - CI: split offline unit tests (hard gate) from informational network tests, and pass FR24 secrets through when present. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 20 +- .gitignore | 4 + pyflightdata/common.py | 8 +- pyflightdata/common_fr24.py | 14 +- pyflightdata/conftest.py | 79 +++++++ pyflightdata/flightdata.py | 52 +++-- pyflightdata/pytest.ini | 5 +- pyflightdata/test_online_auth.py | 102 ++++++++ pyflightdata/test_pyflightdata.py | 12 +- pyflightdata/test_unit.py | 375 ++++++++++++++++++++++++++++++ pyflightdata/utils.py | 15 +- pyproject.toml | 1 - 12 files changed, 642 insertions(+), 45 deletions(-) create mode 100644 pyflightdata/conftest.py create mode 100644 pyflightdata/test_online_auth.py create mode 100644 pyflightdata/test_unit.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b76ec47..d216be8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,12 +25,22 @@ jobs: python -m pip install --upgrade pip pip install -e . pip install pytest pytest-cov pytest-html flaky - # These tests call flightradar24 directly; Cloudflare blocks GitHub-hosted - # runner IPs regardless of TLS impersonation, so failures here are expected - # and don't reflect code health. Kept informational rather than a hard gate. - - name: Run tests + # Offline unit tests (test_unit.py) exercise pure parsing/filtering/state + # logic with no network access, so they are a real gate: if these break, + # fail the build. + - name: Run offline unit tests + run: pytest pyflightdata/test_unit.py + # The remaining tests call flightradar24 directly; Cloudflare blocks + # GitHub-hosted runner IPs regardless of TLS impersonation, so failures + # here are expected and don't reflect code health. Kept informational. + # Authenticated tests read FR24_EMAIL/FR24_PASSWORD if those repo secrets + # exist, and skip themselves otherwise. + - name: Run integration tests (informational) continue-on-error: true - run: pytest pyflightdata + env: + FR24_EMAIL: ${{ secrets.FR24_EMAIL }} + FR24_PASSWORD: ${{ secrets.FR24_PASSWORD }} + run: pytest pyflightdata --ignore=pyflightdata/test_unit.py publish: if: github.event_name == 'release' && github.event.action == 'published' diff --git a/.gitignore b/.gitignore index d2de837..e695083 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,10 @@ htmlcov/ .cache nosetests.xml coverage.xml +# pytest-html report generated by the forced --html addopts in pytest.ini +/test_report.html +/assets/ +.pytest_cache/ # Translations *.mo diff --git a/pyflightdata/common.py b/pyflightdata/common.py index 99a0d14..08eef79 100644 --- a/pyflightdata/common.py +++ b/pyflightdata/common.py @@ -21,7 +21,6 @@ # SOFTWARE. import json -import sys import time from bs4 import BeautifulSoup @@ -54,8 +53,6 @@ def _byteify(self, data, ignore_dicts=False): # if this is a unicode string, return its string representation if isinstance(data, str): return self.encode_and_get(data) - if sys.version_info[0] < 3 and isinstance(data, unicode): - return self.encode_and_get(data) # if this is a list of values, return list of byteified values if isinstance(data, list): return [self._byteify(item, ignore_dicts=True) for item in data] @@ -159,7 +156,4 @@ def get_raw_data_json(self, url, path): pass def encode_and_get(self, string): - if sys.version_info[0] < 3: - return string.encode('unicode-escape').replace('\\xa0', ' ') - else: - return string.encode('unicode-escape').replace(b'\\xa0', b' ').decode('utf-8') + return string.encode('unicode-escape').replace(b'\\xa0', b' ').decode('utf-8') diff --git a/pyflightdata/common_fr24.py b/pyflightdata/common_fr24.py index 6e31597..b90ca12 100644 --- a/pyflightdata/common_fr24.py +++ b/pyflightdata/common_fr24.py @@ -255,13 +255,21 @@ def get_raw_airline_fleet_data(self, url): def process_raw_airline_fleet_data(self, data, authenticated): result = [] for parent in data: - record = {} cells = parent.find_all('td') - record['reg'] = cells[0].find('a').text.strip() + # Rows without at least the reg + type columns are layout/header + # artifacts from flightradar24's markup, not fleet entries; skip them + # instead of raising IndexError when the site tweaks its HTML. + if len(cells) < 2: + continue + reg_link = cells[0].find('a') + if reg_link is None: + continue + record = {} + record['reg'] = reg_link.text.strip() record['type'] = cells[1].text.strip() record['msn'] = 'requires user login' record['age'] = 'requires user login' - if authenticated: + if authenticated and len(cells) >= 4: record['msn'] = cells[2].text.strip() record['age'] = cells[3].text.strip() result.append(record) diff --git a/pyflightdata/conftest.py b/pyflightdata/conftest.py new file mode 100644 index 0000000..85527b3 --- /dev/null +++ b/pyflightdata/conftest.py @@ -0,0 +1,79 @@ +# MIT License +# +# Copyright (c) 2020 Hari Allamraju +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +"""Shared pytest fixtures. + +The authenticated online tests need real flightradar24 credentials. They are +read, in order of precedence, from: + +1. the ``FR24_EMAIL`` / ``FR24_PASSWORD`` environment variables (use these in + CI secrets), or +2. a local ``.env`` file at the repository root with ``email=`` / ``password=`` + lines (gitignored, handy for local runs). + +When no credentials are available the ``credentials`` fixture skips the test, so +the suite stays green on CI runners that have no secret configured. +""" + +import os + +import pytest + + +def _read_env_file(path): + values = {} + try: + with open(path) as handle: + for line in handle: + line = line.strip() + if not line or line.startswith('#') or '=' not in line: + continue + key, _, value = line.partition('=') + values[key.strip()] = value.strip().strip('"').strip("'") + except OSError: + pass + return values + + +def load_credentials(): + """Return ``(email, password)`` or ``(None, None)`` if not configured.""" + email = os.environ.get('FR24_EMAIL') + password = os.environ.get('FR24_PASSWORD') + if email and password: + return email, password + env_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), '.env') + values = _read_env_file(env_path) + email = email or values.get('FR24_EMAIL') or values.get('email') + password = password or values.get('FR24_PASSWORD') or values.get('password') + if email and password: + return email, password + return None, None + + +@pytest.fixture(scope='session') +def credentials(): + email, password = load_credentials() + if not (email and password): + pytest.skip( + 'no flightradar24 credentials; set FR24_EMAIL/FR24_PASSWORD or a ' + 'local .env with email=/password=') + return email, password diff --git a/pyflightdata/flightdata.py b/pyflightdata/flightdata.py index d7fef74..5eb8fc7 100644 --- a/pyflightdata/flightdata.py +++ b/pyflightdata/flightdata.py @@ -661,25 +661,41 @@ def login(self, email, password): f=FlightData() f.login(myemail,mypassword) + Returns: + bool: True if the login succeeded and an auth token was obtained, + False otherwise (bad credentials, network error, or an unexpected + response shape from flightradar24). + """ - response = FlightData.session.post( - url=LOGIN_URL, - data={ - 'email': email, - 'password': password, - 'remember': 'true', - 'type': 'web' - }, - headers={ - 'Origin': 'https://www.flightradar24.com', - 'Referer': 'https://www.flightradar24.com' - } - ) - response = self._fr24.json_loads_byteified( - response.content) if response.status_code == 200 else None - if response: - token = response['userData']['subscriptionKey'] - self.AUTH_TOKEN = token + try: + response = FlightData.session.post( + url=LOGIN_URL, + data={ + 'email': email, + 'password': password, + 'remember': 'true', + 'type': 'web' + }, + headers={ + 'Origin': 'https://www.flightradar24.com', + 'Referer': 'https://www.flightradar24.com' + } + ) + except Exception: + return False + if response.status_code != 200: + return False + try: + payload = self._fr24.json_loads_byteified(response.content) + token = payload['userData']['subscriptionKey'] + except (ValueError, KeyError, TypeError): + # ValueError: body was not JSON; KeyError/TypeError: JSON shape + # changed and the token is no longer where we expect it. + return False + if not token: + return False + self.AUTH_TOKEN = token + return True def logout(self): """Logout from the flightradar24 session. diff --git a/pyflightdata/pytest.ini b/pyflightdata/pytest.ini index 54f2f32..1c0a67e 100644 --- a/pyflightdata/pytest.ini +++ b/pyflightdata/pytest.ini @@ -1,3 +1,6 @@ [pytest] addopts = --html=test_report.html -python_files = *.py \ No newline at end of file +python_files = test_*.py +markers = + online: test hits the live flightradar24 site (may fail behind Cloudflare) + auth: test needs flightradar24 credentials (skipped when none configured) diff --git a/pyflightdata/test_online_auth.py b/pyflightdata/test_online_auth.py new file mode 100644 index 0000000..2663755 --- /dev/null +++ b/pyflightdata/test_online_auth.py @@ -0,0 +1,102 @@ +# MIT License +# +# Copyright (c) 2020 Hari Allamraju +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +"""Authenticated online tests against the live flightradar24 site. + +These require real credentials (see ``conftest.load_credentials``) and are +skipped automatically when none are configured, so they never break CI. The +premise, per the flightradar24 plans, is that anonymous and paid access differ +mainly in how far back the history goes: a logged-in client should therefore see +*at least* as much history as an anonymous one within the same page budget. +""" + +import time + +import pytest +from flaky import flaky + +from .flightdata import FlightData + + +def delay_rerun(*args): + time.sleep(5) + return True + + +def _history_depth(client, tail, max_pages=3): + """Count history rows for ``tail`` across up to ``max_pages`` pages. + + ``FlightData._fr24`` holds pagination state and is shared across every + ``FlightData`` instance, so callers must never interleave two clients' + paged reads. We reset that shared state before and after each pass. + """ + client.clear_last_request() + total = 0 + for page in range(1, max_pages + 1): + rows = client.get_history_by_tail_number(tail, page=page) + total += len(rows) + if not rows: + break + time.sleep(2) + client.clear_last_request() + return total + + +@pytest.mark.online +@pytest.mark.auth +@flaky(max_runs=3, rerun_filter=delay_rerun) +class TestAuthenticatedAccess(object): + + # A busy long-haul aircraft, so there is plenty of history to page through. + TAIL = '9V-SMC' + + def teardown(self): + time.sleep(3) + + def test_login_then_logout(self, credentials): + email, password = credentials + f = FlightData() + assert f.is_authenticated() is False + assert f.login(email, password) is True + assert f.is_authenticated() is True + f.logout() + assert f.is_authenticated() is False + + def test_login_via_constructor(self, credentials): + email, password = credentials + f = FlightData(email, password) + assert f.is_authenticated() is True + + def test_authenticated_returns_history(self, credentials): + email, password = credentials + f = FlightData(email, password) + assert _history_depth(f, self.TAIL) > 0 + + def test_authenticated_history_at_least_anonymous(self, credentials): + email, password = credentials + # Sequential, never interleaved, because _fr24 pagination state is shared. + anon_depth = _history_depth(FlightData(), self.TAIL) + auth_depth = _history_depth(FlightData(email, password), self.TAIL) + assert auth_depth > 0 + # Paid history reaches at least as far back as anonymous within the same + # page budget; the only advertised difference between the tiers is depth. + assert auth_depth >= anon_depth diff --git a/pyflightdata/test_pyflightdata.py b/pyflightdata/test_pyflightdata.py index b3b151d..5468c43 100644 --- a/pyflightdata/test_pyflightdata.py +++ b/pyflightdata/test_pyflightdata.py @@ -52,11 +52,11 @@ def test_check_there_is_history_data(self): result = f.get_history_by_flight_number('AI101') assert result.__len__() > 0 - def test_check_there_is_history_data(self): + def test_flight_for_today_has_data(self): result = f.get_flight_for_date('AI101',self.t) assert result.__len__() > 0 - def test_check_there_is_history_data(self): + def test_flight_for_far_future_has_no_data(self): result = f.get_flight_for_date('AI101',self.tpls90) assert result.__len__() == 0 @@ -136,16 +136,16 @@ def test_get_airport_arrivals(self): def test_get_airport_departures(self): assert f.get_airport_departures('SIN').__len__() >= 0 - def test_get_airport_departures_earlier(self): + def test_get_airport_departures_earlier_pmf(self): assert f.get_airport_departures('PMF', earlier_data=True).__len__() >= 0 - def test_get_airport_departures_no_earlier(self): + def test_get_airport_departures_no_earlier_sin(self): assert f.get_airport_departures('SIN', earlier_data=False).__len__() >= 0 - def test_get_airport_departures_earlier(self): + def test_get_airport_departures_earlier_gau(self): assert f.get_airport_departures('GAU', earlier_data=True).__len__() >= 0 - def test_get_airport_departures_no_earlier(self): + def test_get_airport_departures_no_earlier_gau(self): assert f.get_airport_departures('GAU', earlier_data=False).__len__() >= 0 def test_get_airport_onground(self): diff --git a/pyflightdata/test_unit.py b/pyflightdata/test_unit.py new file mode 100644 index 0000000..a06cb15 --- /dev/null +++ b/pyflightdata/test_unit.py @@ -0,0 +1,375 @@ +# MIT License +# +# Copyright (c) 2020 Hari Allamraju +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +"""Offline unit tests. + +Unlike the other ``test_*`` modules in this package, nothing here touches the +network. Every test exercises pure parsing/filtering/state logic, so the suite +runs deterministically in CI where flightradar24 is unreachable (Cloudflare +blocks GitHub-hosted runners). Anything that would make an HTTP request is +either fed canned input or monkeypatched. +""" + +import time +from datetime import datetime + +from bs4 import BeautifulSoup + +from . import conftest +from .common import ProcessorMixin +from .common_fr24 import FR24 +from .flightdata import FlightData +from .json_helper import fltr +from .utils import nowtimestamp_millis, totimestamp + + +# --------------------------------------------------------------------------- +# json_helper.fltr +# --------------------------------------------------------------------------- + +class TestFltr(object): + + def test_removes_top_level_and_nested_keys(self): + node = {'id': 1, 'name': 'AI101', 'nested': {'hex': 'abc', 'keep': 'v'}} + out = fltr(node, ['id', 'hex']) + assert 'id' not in out + assert out['name'] == 'AI101' + assert 'hex' not in out['nested'] + assert out['nested']['keep'] == 'v' + + def test_non_string_scalars_are_preserved(self): + out = fltr({'count': 3, 'ratio': 1.5}, []) + assert out['count'] == 3 + assert out['ratio'] == 1.5 + + def test_bool_is_kept_as_bool(self): + # isinstance(True, int) is True in Python, so bools take the int branch + # and are returned unchanged rather than stringified. + assert fltr({'flag': True}, [])['flag'] is True + + def test_timestamp_keys_are_expanded(self): + out = fltr({'updated': 1609459200}, []) + assert out['updated'] == 1609459200 + assert out['updated_millis'] == 1609459200 * 1000 + # date/time strings are local-tz dependent; assert shape not value + assert len(out['updated_date']) == 8 and out['updated_date'].isdigit() + assert len(out['updated_time']) == 4 and out['updated_time'].isdigit() + + def test_non_timestamp_int_is_not_expanded(self): + out = fltr({'seats': 180}, []) + assert out['seats'] == 180 + assert 'seats_millis' not in out + + def test_empty_dict_returns_none(self): + assert fltr({}, []) is None + + def test_dict_reduced_to_empty_returns_none(self): + assert fltr({'id': 1}, ['id']) is None + + def test_empty_list_returns_none(self): + assert fltr([], []) is None + + def test_list_drops_entries_that_reduce_to_empty(self): + out = fltr([{'id': 1}, {'name': 'x'}], ['id']) + assert out == [{'name': 'x'}] + + +# --------------------------------------------------------------------------- +# utils +# --------------------------------------------------------------------------- + +class TestUtils(object): + + def test_totimestamp_epoch_is_zero(self): + assert totimestamp(datetime(1970, 1, 1)) == 0 + + def test_totimestamp_known_value(self): + # 2021-01-01T00:00:00 == 1609459200 seconds since the epoch + assert totimestamp(datetime(2021, 1, 1)) == 1609459200 + + def test_nowtimestamp_is_int_seconds(self): + v = nowtimestamp_millis() + assert isinstance(v, int) + # within a few seconds of the real epoch-seconds clock + assert abs(v - int(time.time())) < 5 + + +# --------------------------------------------------------------------------- +# common.ProcessorMixin +# --------------------------------------------------------------------------- + +class TestProcessorMixin(object): + + p = ProcessorMixin() + + def test_json_loads_byteified_from_bytes(self): + out = self.p.json_loads_byteified(b'{"a": "b", "n": 1}') + assert out['a'] == 'b' + assert out['n'] == 1 + + def test_json_loads_byteified_from_str(self): + out = self.p.json_loads_byteified('{"nested": {"k": "v"}}') + assert out['nested']['k'] == 'v' + + def test_encode_and_get_plain_string(self): + assert self.p.encode_and_get('hello') == 'hello' + + def test_encode_and_get_replaces_nbsp_with_space(self): + # a non-breaking space (\xa0) should come back as a regular space + assert self.p.encode_and_get('a\xa0b') == 'a b' + + def test_get_soup_or_none_on_bad_input(self): + # BeautifulSoup happily parses None-ish content only when given bytes/str; + # None triggers the guarded except and yields None + assert self.p.get_soup_or_none(None) is None + + +# --------------------------------------------------------------------------- +# common_fr24.FR24 - pure logic, no network +# --------------------------------------------------------------------------- + +def _rows_from_html(html): + return BeautifulSoup(html, 'html5lib').find_all('tr') + + +class TestFR24State(object): + + def test_check_last_key_resets_timestamp_on_new_key(self): + fr = FR24() + fr.timestamp = '999' + fr.last_key = 'AI101' + fr.check_last_key('AI202') + assert fr.timestamp == '' + assert fr.last_key == 'AI202' + + def test_check_last_key_keeps_timestamp_for_same_key(self): + fr = FR24() + fr.timestamp = '999' + fr.last_key = 'AI101' + fr.check_last_key('AI101') + assert fr.timestamp == '999' + + def test_filter_and_get_data_handles_empty(self): + fr = FR24() + assert fr.filter_and_get_data(None) == [] + assert fr.filter_and_get_data([]) == [] + + def test_filter_and_get_data_filters_noise_keys(self): + fr = FR24() + out = fr.filter_and_get_data([{'id': 1, 'callsign': 'SIA', 'logo': 'x'}]) + assert 'id' not in out + assert 'logo' not in out + assert out['callsign'] == 'SIA' + + def test_process_raw_flight_data_captures_last_timestamp(self): + fr = FR24() + data = [ + {'time': {'other': {'updated': 111}}}, + {'time': {'other': {'updated': 222}}}, + ] + result = fr.process_raw_flight_data(data) + assert result is data + assert fr.timestamp == 222 + + def test_process_raw_flight_data_empty_clears_timestamp(self): + fr = FR24() + fr.timestamp = 'stale' + fr.process_raw_flight_data([]) + assert fr.timestamp == '' + + +class TestFR24FleetParsing(object): + + FLEET_HTML = ( + '' + '' + '
9V-ABCA35912345.6
' + ) + + def test_unauthenticated_masks_msn_and_age(self): + fr = FR24() + out = fr.process_raw_airline_fleet_data(_rows_from_html(self.FLEET_HTML), False) + assert out[0]['reg'] == '9V-ABC' + assert out[0]['type'] == 'A359' + assert out[0]['msn'] == 'requires user login' + assert out[0]['age'] == 'requires user login' + + def test_authenticated_exposes_msn_and_age(self): + fr = FR24() + out = fr.process_raw_airline_fleet_data(_rows_from_html(self.FLEET_HTML), True) + assert out[0]['msn'] == '1234' + assert out[0]['age'] == '5.6' + + def test_row_without_anchor_is_skipped(self): + fr = FR24() + html = '
xy
' + out = fr.process_raw_airline_fleet_data(_rows_from_html(html), False) + assert out == [] + + def test_short_row_is_skipped(self): + fr = FR24() + html = '
only-one-cell
' + out = fr.process_raw_airline_fleet_data(_rows_from_html(html), False) + assert out == [] + + def test_authenticated_short_row_falls_back_to_masked(self): + # authenticated but the site only returned reg + type columns + fr = FR24() + html = '
9V-XYZB789
' + out = fr.process_raw_airline_fleet_data(_rows_from_html(html), True) + assert out[0]['reg'] == '9V-XYZ' + assert out[0]['msn'] == 'requires user login' + + +# --------------------------------------------------------------------------- +# flightdata.FlightData - offline behaviour and monkeypatched I/O +# --------------------------------------------------------------------------- + +class TestFlightDataState(object): + + def test_logout_resets_authentication(self): + f = FlightData() + f.AUTH_TOKEN = 'tok' + assert f.is_authenticated() is True + f.logout() + assert f.is_authenticated() is False + + def test_fresh_instance_is_not_authenticated(self): + assert FlightData().is_authenticated() is False + + def test_clear_last_request_resets_fr24_state(self): + f = FlightData() + f._fr24.timestamp = 'x' + f._fr24.last_key = 'y' + f.clear_last_request() + assert f._fr24.timestamp == '' + assert f._fr24.last_key == '' + + +class TestGetFlightForDate(object): + + def test_keeps_only_flights_matching_the_date(self, monkeypatch): + f = FlightData() + match = {'time': {'scheduled': {'arrival_date': '20210101'}}} + nomatch = {'time': {'scheduled': {'arrival_date': '20991231'}}} + monkeypatch.setattr(f, 'get_history_by_flight_number', + lambda flight_number: [match, nomatch]) + assert f.get_flight_for_date('AI101', '20210101') == [match] + + def test_matches_on_departure_date_too(self, monkeypatch): + f = FlightData() + dep = {'time': {'scheduled': {'departure_date': '20210101'}}} + monkeypatch.setattr(f, 'get_history_by_flight_number', + lambda flight_number: [dep]) + assert f.get_flight_for_date('AI101', '20210101') == [dep] + + def test_returns_empty_when_nothing_matches(self, monkeypatch): + f = FlightData() + monkeypatch.setattr(f, 'get_history_by_flight_number', + lambda flight_number: [ + {'time': {'scheduled': {'arrival_date': '19990101'}}}]) + assert f.get_flight_for_date('AI101', '20210101') == [] + + +class TestGetAirportWeather(object): + + def test_adds_km_when_visibility_present(self, monkeypatch): + f = FlightData() + weather = {'sky': {'visibility': {'mi': '10'}}} + monkeypatch.setattr(f._fr24, 'get_airport_weather', lambda url: weather) + out = f.get_airport_weather('SIN') + assert out['sky']['visibility']['km'] == 10 * 1.6094 + + def test_no_km_when_visibility_is_none(self, monkeypatch): + f = FlightData() + weather = {'sky': {'visibility': {'mi': 'None'}}} + monkeypatch.setattr(f._fr24, 'get_airport_weather', lambda url: weather) + out = f.get_airport_weather('SIN') + assert 'km' not in out['sky']['visibility'] + + def test_missing_weather_structure_is_swallowed(self, monkeypatch): + f = FlightData() + monkeypatch.setattr(f._fr24, 'get_airport_weather', lambda url: []) + # should not raise even though [] has no 'sky' key + assert f.get_airport_weather('SIN') == [] + + +class TestLogin(object): + + class _Resp(object): + def __init__(self, status_code, content): + self.status_code = status_code + self.content = content + + def test_successful_login_sets_token(self, monkeypatch): + f = FlightData() + payload = b'{"userData": {"subscriptionKey": "secret-token"}}' + monkeypatch.setattr(FlightData.session, 'post', + lambda **kw: self._Resp(200, payload)) + assert f.login('a@b.com', 'pw') is True + assert f.AUTH_TOKEN == 'secret-token' + f.logout() + + def test_non_200_returns_false(self, monkeypatch): + f = FlightData() + monkeypatch.setattr(FlightData.session, 'post', + lambda **kw: self._Resp(403, b'')) + assert f.login('a@b.com', 'pw') is False + assert f.is_authenticated() is False + + def test_unexpected_shape_returns_false(self, monkeypatch): + f = FlightData() + monkeypatch.setattr(FlightData.session, 'post', + lambda **kw: self._Resp(200, b'{"unexpected": true}')) + assert f.login('a@b.com', 'pw') is False + assert f.is_authenticated() is False + + def test_network_error_returns_false(self, monkeypatch): + f = FlightData() + + def _boom(**kw): + raise ConnectionError('down') + + monkeypatch.setattr(FlightData.session, 'post', _boom) + assert f.login('a@b.com', 'pw') is False + + +# --------------------------------------------------------------------------- +# conftest credential loader (offline) +# --------------------------------------------------------------------------- + +class TestCredentialLoader(object): + + def test_env_vars_take_precedence(self, monkeypatch): + monkeypatch.setenv('FR24_EMAIL', 'env@x.com') + monkeypatch.setenv('FR24_PASSWORD', 'env-pw') + assert conftest.load_credentials() == ('env@x.com', 'env-pw') + + def test_reads_key_value_env_file(self, tmp_path): + env = tmp_path / '.env' + env.write_text('email=a@b.com\npassword="secret"\n# a comment\n\n') + values = conftest._read_env_file(str(env)) + assert values['email'] == 'a@b.com' + assert values['password'] == 'secret' + + def test_missing_env_file_yields_empty(self, tmp_path): + assert conftest._read_env_file(str(tmp_path / 'absent.env')) == {} diff --git a/pyflightdata/utils.py b/pyflightdata/utils.py index 38efcce..d1a4062 100644 --- a/pyflightdata/utils.py +++ b/pyflightdata/utils.py @@ -25,12 +25,19 @@ """ from __future__ import print_function -from datetime import datetime, timedelta +from datetime import datetime, timezone -def totimestamp(dt, epoch=datetime(1970,1,1)): + +def totimestamp(dt, epoch=datetime(1970, 1, 1)): td = dt - epoch # return td.total_seconds() - return (td.microseconds + (td.seconds + td.days * 86400) * 10**6) / 10**6 + return (td.microseconds + (td.seconds + td.days * 86400) * 10 ** 6) / 10 ** 6 + def nowtimestamp_millis(): - return totimestamp(datetime.utcnow()) \ No newline at end of file + # NOTE: despite the historical name, flightradar24's schedule ``timestamp`` + # parameter expects whole epoch *seconds*, so that is what we return. + # ``datetime.utcnow()`` is deprecated as of Python 3.12; use a timezone-aware + # UTC ``now`` instead. An int (not a float) is returned so the value goes + # into the request URL as e.g. ``1700000000`` rather than ``1700000000.66``. + return int(datetime.now(timezone.utc).timestamp()) \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index c3b1551..a315ffe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,6 @@ classifiers = [ "License :: OSI Approved :: MIT License", ] dependencies = [ - "requests", "curl_cffi", "lxml>=4.4.1", "beautifulsoup4>=4.8.1",