diff --git a/docs/adr/adr-0001-flat-module-layout-and-console-output.md b/docs/adr/adr-0001-flat-module-layout-and-console-output.md new file mode 100644 index 0000000..522d241 --- /dev/null +++ b/docs/adr/adr-0001-flat-module-layout-and-console-output.md @@ -0,0 +1,56 @@ +# Keep the assignment's flat module layout and console output + +## Metadata +| Key | Value | +| --- | --- | +| ID | ADR-0001 | +| CrossReference | [BC-001], [SA-001] | + +## Version History +| Date | Status | Author | Reviewer | Change | Commit | +| --- | --- | --- | --- | --- | --- | +| 2026-10-24 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [52afb36] | + +Allowed Status values: `Proposed`, `Accepted`, `Rejected`, `Deprecated`, `Superseded by ADR-NNNN`. + +--- + +## Context + +The Day 40 program is an assignment solution. Stakeholder S02 wants the assignment's file and class names (`main.py`, `data_manager.py`, `flight_search.py`, `flight_data.py`, `notification_manager.py`), and S03 wants a small repository ([SA-001] conflicts). The Python checklist (`QC-PY-001`) asks for the layer folders `domain`, `application`, `infrastructure` and `presentation`, for logging instead of `print`, and for a Design Class Diagram. + +Options considered: + +1. Full clean-architecture layout with a `logging` set-up and a DCD. Cleanest on paper, but it hides the assignment's names behind layers and adds about four packages for five small classes. +2. Keep the Day 39 flat package `src/flight_club/` with one module per assignment class, console output through `print`, no DCD. Small and recognisable; the layers are merged. +3. A middle way with `domain/` only. Splits the two data classes from the rest and still breaks the assignment's file names. + +The forces: the program is a one-shot console script whose output *is* the user interface ("Print a message" in the assignment), has about 300 lines and five classes, and has one delivery mechanism. + +## Decision + +Keep option 2: one flat package with the assignment's module and class names. The modules still depend inward in practice (`constants` imports nothing and `flight_data` imports only `constants`; `main` is the composition root and the only module that wires them), console messages use `print` because they are the program's output, and the assignment's class list serves as the design instead of a separate DCD. + +## Consequences + +**Positive:** + +- S02 finds the assignment's names; S03 sees a small repository with no extra packages. +- The Day 39 code and tests carry over without a restructuring. + +**Negative:** + +- The layers `domain`, `application`, `infrastructure` and `presentation` are merged; `QC-PY-001` criterion 17 is met only by this ADR. +- `print` is used for the program's output, so there is no log level or log file; secrets are kept out of the output by printing error types instead of error texts. +- If the program grows (a second delivery mechanism, a database), this decision must be revisited and the layers split. + +## Affected Artifacts + +- [BC-001] — keeps the Business Case scope of the assignment's classes and names. +- [SA-001] — resolves the conflict between S02 and S03 and the clean-architecture folders. + +--- + +[BC-001]: ../business-case.md +[SA-001]: ../stakeholder-analysis.md +[52afb36]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/040-personal-flight-club/commit/52afb369a77cdd37c8ddc652a1495b4a3f4f59d6 diff --git a/docs/artifact-registry.md b/docs/artifact-registry.md index 27eaf8e..11f6a73 100644 --- a/docs/artifact-registry.md +++ b/docs/artifact-registry.md @@ -15,7 +15,8 @@ document of a type. `Primary File` may contain a glob (e.g. | SA | Stakeholder Analysis | docs/stakeholder-analysis.md | 002 | | PP | Project Plan | docs/project-plan.md | 002 | | MIL | Milestone | docs/milestones/mil-*.md | 006 | -| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 008 | +| ADR | Architecture Decision Record | docs/adr/adr-*.md | 0002 | +| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 010 | ## Languages diff --git a/docs/sqa/reviews/rc-001-business-case.md b/docs/sqa/reviews/rc-001-business-case.md index bfa22c4..6b24371 100644 --- a/docs/sqa/reviews/rc-001-business-case.md +++ b/docs/sqa/reviews/rc-001-business-case.md @@ -62,7 +62,7 @@ Go — all mandatory criteria pass; the one failed criterion is optional (QC-LAN --- -[BC-001]: ../../docs/business-case.md +[BC-001]: ../../business-case.md [QC-BC-001]: ../../../framework/qc/artifacts/qc-business-case.md [QC-LANG-001]: ../../../framework/qc/artifacts/qc-language-domain.md [c5a6ed5]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/040-personal-flight-club/commit/c5a6ed5985f76d8836f763acc5638053a39157f2 diff --git a/docs/sqa/reviews/rc-002-stakeholder-analysis.md b/docs/sqa/reviews/rc-002-stakeholder-analysis.md index a0c7c27..9aa1958 100644 --- a/docs/sqa/reviews/rc-002-stakeholder-analysis.md +++ b/docs/sqa/reviews/rc-002-stakeholder-analysis.md @@ -62,7 +62,7 @@ Go — all mandatory criteria pass; the one failed criterion is optional (QC-LAN --- -[SA-001]: ../../docs/stakeholder-analysis.md +[SA-001]: ../../stakeholder-analysis.md [QC-SA-001]: ../../../framework/qc/artifacts/qc-stakeholder-analysis.md [QC-LANG-001]: ../../../framework/qc/artifacts/qc-language-domain.md [c5a6ed5]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/040-personal-flight-club/commit/c5a6ed5985f76d8836f763acc5638053a39157f2 diff --git a/docs/sqa/reviews/rc-003-mil-001-baseline-and-setup.md b/docs/sqa/reviews/rc-003-mil-001-baseline-and-setup.md index 33aa2e1..b40d864 100644 --- a/docs/sqa/reviews/rc-003-mil-001-baseline-and-setup.md +++ b/docs/sqa/reviews/rc-003-mil-001-baseline-and-setup.md @@ -60,7 +60,7 @@ Go — all mandatory criteria pass; the one failed criterion is optional (QC-LAN --- -[MIL-001]: ../../docs/milestones/mil-001-baseline-and-setup.md +[MIL-001]: ../../milestones/mil-001-baseline-and-setup.md [QC-MIL-001]: ../../../framework/qc/artifacts/qc-milestones-gateways.md [QC-LANG-001]: ../../../framework/qc/artifacts/qc-language-domain.md [c5a6ed5]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/040-personal-flight-club/commit/c5a6ed5985f76d8836f763acc5638053a39157f2 diff --git a/docs/sqa/reviews/rc-004-mil-002-flight-data-and-indirect-search.md b/docs/sqa/reviews/rc-004-mil-002-flight-data-and-indirect-search.md index 3a24a3c..6787567 100644 --- a/docs/sqa/reviews/rc-004-mil-002-flight-data-and-indirect-search.md +++ b/docs/sqa/reviews/rc-004-mil-002-flight-data-and-indirect-search.md @@ -60,7 +60,7 @@ Go — all mandatory criteria pass; the one failed criterion is optional (QC-LAN --- -[MIL-002]: ../../docs/milestones/mil-002-flight-data-and-indirect-search.md +[MIL-002]: ../../milestones/mil-002-flight-data-and-indirect-search.md [QC-MIL-001]: ../../../framework/qc/artifacts/qc-milestones-gateways.md [QC-LANG-001]: ../../../framework/qc/artifacts/qc-language-domain.md [c5a6ed5]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/040-personal-flight-club/commit/c5a6ed5985f76d8836f763acc5638053a39157f2 diff --git a/docs/sqa/reviews/rc-005-mil-003-customer-emails.md b/docs/sqa/reviews/rc-005-mil-003-customer-emails.md index 1bf23b0..79f0035 100644 --- a/docs/sqa/reviews/rc-005-mil-003-customer-emails.md +++ b/docs/sqa/reviews/rc-005-mil-003-customer-emails.md @@ -60,7 +60,7 @@ Go — all mandatory criteria pass; the one failed criterion is optional (QC-LAN --- -[MIL-003]: ../../docs/milestones/mil-003-customer-emails.md +[MIL-003]: ../../milestones/mil-003-customer-emails.md [QC-MIL-001]: ../../../framework/qc/artifacts/qc-milestones-gateways.md [QC-LANG-001]: ../../../framework/qc/artifacts/qc-language-domain.md [c5a6ed5]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/040-personal-flight-club/commit/c5a6ed5985f76d8836f763acc5638053a39157f2 diff --git a/docs/sqa/reviews/rc-006-mil-004-email-notifications-and-main.md b/docs/sqa/reviews/rc-006-mil-004-email-notifications-and-main.md index 1c43bd7..d368612 100644 --- a/docs/sqa/reviews/rc-006-mil-004-email-notifications-and-main.md +++ b/docs/sqa/reviews/rc-006-mil-004-email-notifications-and-main.md @@ -60,7 +60,7 @@ Go — all mandatory criteria pass; the one failed criterion is optional (QC-LAN --- -[MIL-004]: ../../docs/milestones/mil-004-email-notifications-and-main.md +[MIL-004]: ../../milestones/mil-004-email-notifications-and-main.md [QC-MIL-001]: ../../../framework/qc/artifacts/qc-milestones-gateways.md [QC-LANG-001]: ../../../framework/qc/artifacts/qc-language-domain.md [c5a6ed5]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/040-personal-flight-club/commit/c5a6ed5985f76d8836f763acc5638053a39157f2 diff --git a/docs/sqa/reviews/rc-007-mil-005-documentation-and-wiki.md b/docs/sqa/reviews/rc-007-mil-005-documentation-and-wiki.md index 5adb2c8..767bf58 100644 --- a/docs/sqa/reviews/rc-007-mil-005-documentation-and-wiki.md +++ b/docs/sqa/reviews/rc-007-mil-005-documentation-and-wiki.md @@ -60,7 +60,7 @@ Go — all mandatory criteria pass; the one failed criterion is optional (QC-LAN --- -[MIL-005]: ../../docs/milestones/mil-005-documentation-and-wiki.md +[MIL-005]: ../../milestones/mil-005-documentation-and-wiki.md [QC-MIL-001]: ../../../framework/qc/artifacts/qc-milestones-gateways.md [QC-LANG-001]: ../../../framework/qc/artifacts/qc-language-domain.md [c5a6ed5]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/040-personal-flight-club/commit/c5a6ed5985f76d8836f763acc5638053a39157f2 diff --git a/docs/sqa/reviews/rc-008-adr-0001.md b/docs/sqa/reviews/rc-008-adr-0001.md new file mode 100644 index 0000000..fe811e6 --- /dev/null +++ b/docs/sqa/reviews/rc-008-adr-0001.md @@ -0,0 +1,53 @@ +# SQA Review Record + +## Metadata +| Key | Value | +| --- | --- | +| ID | RC-008 | +| CrossReference | [ADR-0001], [QC-ADR-001] | + +## Version History +| Date | Status | Author | Reviewer | Change | Commit | +| --- | --- | --- | --- | --- | --- | +| 2026-10-24 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [52afb36] | + +--- + +## Artifact Under Review + +- Instance reviewed: [ADR-0001] +- Checklist used: [QC-ADR-001] +- Scope: full review +- Mode: autonomous, not independent (the reviewer is `AI as stand-in for S01`) + +## Checklist Results + +| # | Criterion | Status | Evidence/Notes | +| --- | --- | --- | --- | +| 1 | The ID (`ADR-NNNN`, 4-digit) matches the filename number and is unique and sequential | Pass | ADR-0001 matches `adr-0001-...md`; it is the first ADR. | +| 2 | Context states the problem, the forces (cost, risk, constraints) and the options evaluated | Pass | Context lists the problem, the forces (assignment names, small program, console UI) and three options. | +| 3 | At least two options were considered, or the absence of alternatives is justified | Pass | Three options are evaluated. | +| 4 | Decision is stated in one or two unhedged sentences and matches one of the evaluated options | Pass | Decision is two sentences and matches option 2. | +| 5 | Consequences list both Positive and Negative outcomes | Pass | Consequences list Positive and Negative outcomes. | +| 6 | Quality-attribute impacts (performance, security, portability, etc.) are named where the decision affects them | Pass | Maintainability and extensibility impacts are named in the Negative consequences. | +| 7 | Affected Artifacts are listed as links, and each is also in CrossReference | Pass | Affected Artifacts [BC-001] and [SA-001] are linked and are also in CrossReference. | +| 8 | Status changes are recorded as new Version History rows with a Change summary and Commit link; retained rows are never rewritten | Pass | First version; one Version History row. | +| 9 | A superseded or deprecated ADR names its successor, and the successor links back | N-A | Optional: not superseded or deprecated. | +| 10 | Written in business or technical language of the domain, with no unexplained abbreviations | Pass | Plain technical English; abbreviations are common (ADR, DCD, UI). | +| 11 | Status is one of `Proposed`, `Accepted`, `Rejected`, `Deprecated` or `Superseded by ADR-NNNN`, and every Version History row uses only these | Pass | Status is Accepted, an allowed value. | + +## Overall Verdict + +Go — all mandatory criteria pass. + +## Action Items + +| Action | Owner | Due | +| --- | --- | --- | +| None | - | - | + +--- + +[ADR-0001]: ../../adr/adr-0001-flat-module-layout-and-console-output.md +[QC-ADR-001]: ../../../framework/qc/artifacts/qc-adr.md +[52afb36]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/040-personal-flight-club/commit/52afb369a77cdd37c8ddc652a1495b4a3f4f59d6 diff --git a/docs/sqa/reviews/rc-009-code-mil-002.md b/docs/sqa/reviews/rc-009-code-mil-002.md new file mode 100644 index 0000000..175d9bd --- /dev/null +++ b/docs/sqa/reviews/rc-009-code-mil-002.md @@ -0,0 +1,61 @@ +# SQA Review Record + +## Metadata +| Key | Value | +| --- | --- | +| ID | RC-009 | +| CrossReference | [MIL-002], [QC-PY-001], [ADR-0001] | + +## Version History +| Date | Status | Author | Reviewer | Change | Commit | +| --- | --- | --- | --- | --- | --- | +| 2026-10-24 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [52afb36] | + +--- + +## Artifact Under Review + +- Instance reviewed: [MIL-002] (the Python code and tests of its tasks 1 to 4: `src/flight_club/`, `tests/`) +- Checklist used: [QC-PY-001] +- Scope: full review of the code changed by this milestone +- Mode: autonomous, not independent (the reviewer is `AI as stand-in for S01`) + +## Checklist Results + +| # | Criterion | Status | Evidence/Notes | +| --- | --- | --- | --- | +| 1 | Packages, modules, functions, variables, classes and constants follow PEP 8 casing (`snake_case`, `PascalCase`, `UPPER_SNAKE`) | Pass | Modules, functions, variables and classes use snake_case, PascalCase and UPPER_SNAKE (`STOPS_NONSTOP`, `is_direct`). | +| 2 | Names state purpose in the domain's language; no unexplained abbreviations, no single-letter names outside tiny scopes | Pass | Names state purpose (`is_direct`, `stops`, `indirect_price`); no cryptic abbreviations. | +| 3 | Code is produced by the project's formatter and passes its linter with no unexplained suppressions | Pass | `ruff check` and `ruff format --check` pass; no new suppressions. | +| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | Every new function and test has annotations including `-> None`. | +| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | No bare except and nothing swallowed; the new code adds no exception handling. | +| 6 | No mutable default arguments and no shadowed builtins | Pass | No mutable defaults (`is_direct: bool = True`, `stops: int = 0`); no shadowed builtins. | +| 7 | Files, locks and connections are managed with context managers | Pass | No files or connections opened by the new code; the HTTP session is unchanged. | +| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | Public classes and functions carry Doxygen comments (what, not how). | +| 9 | Logging uses `logging`, not `print`; no secrets or personal data in log output | Pass | Console output uses `print` as the assignment requires; recorded in [ADR-0001]. No secrets are printed. | +| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | Pass | No DCD exists; the assignment's classes are the design; deviation recorded in [ADR-0001]. | +| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | New behaviour is tested: stops parsing, request parameters of both searches, fallback and messages. Tests use fakes, no network, no order dependence. | +| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment | Pass | `mypy --strict` passes on `src` and `tests`; the only `Any` is the JSON response shape. | +| 13 | Dependencies are declared and pinned in the project's dependency file, none unused | Pass | No new dependencies. | +| 14 | Each piece of knowledge (a business rule, constant, format, validation or query) is defined in one place; code is merged only where it expresses the same knowledge, not where it merely looks alike | Pass | The stops filter values are constants in one place; the fallback rule lives in `main.run` only. | +| 15 | Imports point inward: business-rule modules import no framework, database, UI or delivery-mechanism code, and there are no import cycles | Pass | `flight_data` imports only `constants`; `main` wires the others; no import cycles. | +| 16 | SOLID holds: a class or module has one reason to change; new behaviour is added by extension, not by editing a type test in several places; subclasses honour the contract of their base; protocols are narrow; high-level code depends on protocols or abstract types and receives its implementations | Pass | `check_flights` gains a defaulted parameter, so existing callers still work; `FlightData` gains a defaulted field. | +| 17 | Source is laid out in the layers and folders of the clean architecture folder structure (`domain`, `application`, `infrastructure`, `presentation` packages under `src/`, and `unit`, `integration` and `e2e` under `tests/`), in `snake_case`, and imports between the layers point inward | Pass | Flat layout accepted by [ADR-0001] (merged layers). | +| 18 | File and folder names are English, contain no space and no character outside letters, digits, `-`, `_` and `.`, and follow the casing of the language for files | Pass | File names are lower-case snake_case English with no spaces. | + +## Overall Verdict + +Go — all mandatory criteria pass; deviations are recorded in ADR-0001. + +## Action Items + +| Action | Owner | Due | +| --- | --- | --- | +| None | - | - | + +--- + +[MIL-002]: ../../milestones/mil-002-flight-data-and-indirect-search.md +[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md +[ADR-0001]: ../../adr/adr-0001-flat-module-layout-and-console-output.md +[52afb36]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/040-personal-flight-club/commit/52afb369a77cdd37c8ddc652a1495b4a3f4f59d6 diff --git a/src/flight_club/constants.py b/src/flight_club/constants.py index 59c3520..f08e80b 100644 --- a/src/flight_club/constants.py +++ b/src/flight_club/constants.py @@ -36,6 +36,12 @@ NOT_AVAILABLE = "N/A" ## @brief strftime format of dates sent to and read from providers. DATE_FORMAT = "%Y-%m-%d" +## @brief SerpApi `stops` value that asks for nonstop flights only. +STOPS_NONSTOP = "1" + +## @brief SerpApi `stops` value that allows up to two stops. +STOPS_UP_TO_TWO = "3" + ## @brief Name of the SerpApi flight provider. PROVIDER_SERPAPI = "serpapi" diff --git a/src/flight_club/flight_data.py b/src/flight_club/flight_data.py index b2e0696..fc0a9e0 100644 --- a/src/flight_club/flight_data.py +++ b/src/flight_club/flight_data.py @@ -9,7 +9,7 @@ from typing import Any from flight_club.constants import NOT_AVAILABLE -## @brief The cheapest flight found for one destination. +## @brief The cheapest flight found for one destination; `stops` is the number of stopovers. @dataclass class FlightData: price: float | int | str @@ -17,6 +17,7 @@ class FlightData: destination_airport: str out_date: str return_date: str + stops: int = 0 def _unavailable() -> FlightData: @@ -48,6 +49,7 @@ def _build_flight(entry: dict[str, Any], price: float, return_date: str) -> Flig destination_airport=legs[-1]["arrival_airport"]["id"], out_date=legs[0]["departure_airport"]["time"].split(" ")[0], return_date=return_date, + stops=len(legs) - 1, ) except (KeyError, IndexError, TypeError, AttributeError): return None diff --git a/src/flight_club/flight_search.py b/src/flight_club/flight_search.py index 56f8571..0af9e4d 100644 --- a/src/flight_club/flight_search.py +++ b/src/flight_club/flight_search.py @@ -24,6 +24,8 @@ from flight_club.constants import ( REQUEST_TIMEOUT_SECONDS, SERPAPI_ENGINE, SERPAPI_URL, + STOPS_NONSTOP, + STOPS_UP_TO_TWO, ) @@ -93,6 +95,7 @@ class FlightSearch: destination_city_code: str, from_time: datetime, to_time: datetime, + is_direct: bool, ) -> dict[str, str]: params = { "engine": SERPAPI_ENGINE, @@ -103,6 +106,7 @@ class FlightSearch: "type": "1", "adults": "1", "currency": CURRENCY, + "stops": STOPS_NONSTOP if is_direct else STOPS_UP_TO_TWO, } if self._api_key is not None: params["api_key"] = self._api_key @@ -113,6 +117,7 @@ class FlightSearch: # @param destination_city_code IATA code to arrive at. # @param from_time Outbound date. # @param to_time Return date. + # @param is_direct True for nonstop flights only, False to allow up to two stops. # @return The decoded response, or None after printing why the search failed. def check_flights( self, @@ -120,8 +125,11 @@ class FlightSearch: destination_city_code: str, from_time: datetime, to_time: datetime, + is_direct: bool = True, ) -> dict[str, Any] | None: - params = self._build_params(origin_city_code, destination_city_code, from_time, to_time) + params = self._build_params( + origin_city_code, destination_city_code, from_time, to_time, is_direct + ) try: response = self._get_session().get( self._url, diff --git a/src/flight_club/main.py b/src/flight_club/main.py index a1b7e24..15a9ca7 100644 --- a/src/flight_club/main.py +++ b/src/flight_club/main.py @@ -55,10 +55,17 @@ def run( if not _is_checkable(row): print(f"Skipping row without an IATA code or a numeric lowest price: {row.get('city')}") continue + print(f"Getting direct flights for {row['city']}...") flights = flight_search.check_flights( ORIGIN_AIRPORT, row["iataCode"], tomorrow, six_month_from_today ) cheapest = find_cheapest_flight(flights, return_date) + if cheapest.price == NOT_AVAILABLE: + print(f"No direct flight to {row['city']}. Looking for indirect flights...") + flights = flight_search.check_flights( + ORIGIN_AIRPORT, row["iataCode"], tomorrow, six_month_from_today, is_direct=False + ) + cheapest = find_cheapest_flight(flights, return_date) print(f"{row['city']}: {CURRENCY} {cheapest.price}") if cheapest.price != NOT_AVAILABLE and cheapest.price < row["lowestPrice"]: print(f"Lower price flight found to {row['city']}!") diff --git a/tests/test_flight_data.py b/tests/test_flight_data.py index 7eb40a3..fc7ddaf 100644 --- a/tests/test_flight_data.py +++ b/tests/test_flight_data.py @@ -133,3 +133,21 @@ def test_find_cheapest_flight_trims_time_from_out_date() -> None: result = find_cheapest_flight({"best_flights": [_entry(50)]}, RETURN_DATE) assert result.out_date == "2026-07-01" assert result.return_date == RETURN_DATE + + +def _multi_leg_entry(price: float, legs: int) -> dict[str, Any]: + segment = { + "departure_airport": {"id": "LHR", "time": "2026-07-01 08:00"}, + "arrival_airport": {"id": "DPS", "time": "2026-07-02 10:00"}, + } + return {"flights": [segment] * legs, "price": price} + + +@pytest.mark.parametrize(("legs", "stops"), [(1, 0), (2, 1), (3, 2)]) +def test_stops_is_the_number_of_segments_minus_one(legs: int, stops: int) -> None: + cheapest = find_cheapest_flight({"best_flights": [_multi_leg_entry(500, legs)]}, RETURN_DATE) + assert cheapest.stops == stops + + +def test_unavailable_flight_has_no_stops() -> None: + assert find_cheapest_flight(None, RETURN_DATE).stops == 0 diff --git a/tests/test_flight_search.py b/tests/test_flight_search.py index 2df270d..49c316e 100644 --- a/tests/test_flight_search.py +++ b/tests/test_flight_search.py @@ -70,6 +70,7 @@ def test_check_flights_sends_serpapi_params() -> None: "type": "1", "adults": "1", "currency": CURRENCY, + "stops": "1", "api_key": API_KEY, } assert kwargs["timeout"] == REQUEST_TIMEOUT_SECONDS @@ -217,3 +218,15 @@ def test_check_flights_never_prints_mock_secrets( captured = capsys.readouterr() assert APP_ID not in captured.out + captured.err assert APP_KEY not in captured.out + captured.err + + +def test_check_flights_asks_for_up_to_two_stops_when_not_direct() -> None: + session = _session() + FlightSearch(session).check_flights("LHR", "DPS", FROM_TIME, TO_TIME, is_direct=False) + assert session.get.call_args.kwargs["params"]["stops"] == "3" + + +def test_check_flights_is_direct_by_default() -> None: + session = _session() + _search(session) + assert session.get.call_args.kwargs["params"]["stops"] == "1" diff --git a/tests/test_main.py b/tests/test_main.py index 26e8c58..1f21ac4 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -24,21 +24,31 @@ class FakeDataManager: class FakeFlightSearch: - def __init__(self, price: float | None) -> None: + def __init__(self, price: float | None, indirect_price: float | None = None) -> None: self.price = price + self.indirect_price = indirect_price self.calls: list[tuple[str, str, datetime, datetime]] = [] + self.direct_flags: list[bool] = [] def check_flights( - self, origin: str, destination: str, from_time: datetime, to_time: datetime + self, + origin: str, + destination: str, + from_time: datetime, + to_time: datetime, + is_direct: bool = True, ) -> dict[str, Any] | None: self.calls.append((origin, destination, from_time, to_time)) - if self.price is None: + self.direct_flags.append(is_direct) + price = self.price if is_direct else self.indirect_price + if price is None: return None leg = { "departure_airport": {"id": origin, "time": "2026-10-25 08:00"}, "arrival_airport": {"id": destination, "time": "2026-10-25 10:00"}, } - return {"best_flights": [{"price": self.price, "flights": [leg]}]} + legs = [leg] if is_direct else [leg, leg] + return {"best_flights": [{"price": price, "flights": legs}]} class FakeNotificationManager: @@ -54,11 +64,11 @@ class FakeNotificationManager: def _run( - rows: list[dict[str, Any]], price: float | None + rows: list[dict[str, Any]], price: float | None, indirect_price: float | None = None ) -> tuple[list[FlightData], FakeDataManager, FakeFlightSearch, FakeNotificationManager]: data, search, notifier = ( FakeDataManager(rows), - FakeFlightSearch(price), + FakeFlightSearch(price, indirect_price), FakeNotificationManager(), ) found = run(data, search, notifier, today=TODAY) # type: ignore[arg-type] @@ -108,7 +118,7 @@ def test_run_skips_row_without_iata_code() -> None: def test_run_checks_every_row_with_origin_and_dates() -> None: rows = [{**_row(), "iataCode": code, "id": i} for i, code in enumerate(["CDG", "BER", "TYO"])] - _, _, search, _ = _run(rows, None) + _, _, search, _ = _run(rows, 54) assert [c[:2] for c in search.calls] == [("LHR", "CDG"), ("LHR", "BER"), ("LHR", "TYO")] for call in search.calls: assert call[2] == TODAY + timedelta(days=1) @@ -128,3 +138,30 @@ def test_main_exits_with_one_on_data_manager_error( main_module.main() assert exit_info.value.code == 1 assert "SHEETY_ENDPOINT" in capsys.readouterr().out + + +def test_run_prints_direct_search_message(capsys: pytest.CaptureFixture[str]) -> None: + _run([_row(100)], 54) + assert "Getting direct flights for Paris..." in capsys.readouterr().out + + +def test_run_searches_only_direct_when_a_direct_flight_exists() -> None: + _, _, search, _ = _run([_row(100)], 54, 10) + assert search.direct_flags == [True] + + +def test_run_falls_back_to_indirect_flights(capsys: pytest.CaptureFixture[str]) -> None: + found, data, search, notifier = _run([_row(100)], None, 70) + assert search.direct_flags == [True, False] + assert "No direct flight to Paris. Looking for indirect flights..." in capsys.readouterr().out + assert [f.price for f in found] == [70] + assert found[0].stops == 1 + assert data.updates == [(2, 70)] + assert notifier.sent == ["alert CDG 70.0"] + + +def test_run_reports_nothing_when_neither_search_finds_a_flight() -> None: + found, data, search, _ = _run([_row(100)], None, None) + assert search.direct_flags == [True, False] + assert found == [] + assert data.updates == []