"""@file test_cli.py @brief The command line accepts its options and the entry point wires the use case. """ import logging from pathlib import Path import pytest from tests.unit.fakes import FakeGym, no_sleep from tests.unit.test_booking_entry import make_entry from tests.unit.test_models import make_card from snack_and_lift import constants from snack_and_lift.cli import main, parse_args from snack_and_lift.infrastructure.browser import default_profile_dir DUMMY_EMAIL = "dummy@example.invalid" DUMMY_PASSWORD = "dummy-password" @pytest.fixture(autouse=True) def isolated_working_directory(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """Run from an empty folder so the default env file is never a real one.""" monkeypatch.chdir(tmp_path) @pytest.fixture def credentials_env(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv(constants.ACCOUNT_EMAIL_ENV, DUMMY_EMAIL) monkeypatch.setenv(constants.ACCOUNT_PASSWORD_ENV, DUMMY_PASSWORD) def test_headless_is_off_by_default() -> None: assert parse_args([]).headless is False def test_headless_flag_is_read() -> None: assert parse_args(["--headless"]).headless is True def test_browser_stays_open_by_default_when_not_headless() -> None: assert parse_args([]).keep_open is True def test_browser_is_closed_by_default_when_headless() -> None: assert parse_args(["--headless"]).keep_open is False def test_keep_open_flag_overrides_headless_default() -> None: assert parse_args(["--headless", "--keep-open"]).keep_open is True def test_profile_dir_defaults_to_working_directory() -> None: assert parse_args([]).profile_dir == default_profile_dir() def test_profile_dir_option_is_read(tmp_path: Path) -> None: assert parse_args(["--profile-dir", str(tmp_path)]).profile_dir == tmp_path def test_screenshot_dir_is_optional() -> None: assert parse_args([]).screenshot_dir is None @pytest.mark.usefixtures("credentials_env") def test_main_saves_both_screenshots_on_success(tmp_path: Path) -> None: gym = FakeGym() code = main(["--screenshot-dir", str(tmp_path)], lambda *_: gym) assert code == 0 assert gym.logins == [(DUMMY_EMAIL, DUMMY_PASSWORD)] assert gym.screenshots == [ tmp_path / "step-1.png", tmp_path / "step-2.png", tmp_path / "step-3.png", tmp_path / "step-7.png", ] assert [path for _, path in gym.text_screenshots] == [ tmp_path / "step-4.png", tmp_path / "step-5.png", tmp_path / "step-6.png", tmp_path / "step-8.png", tmp_path / "step-9.png", ] assert gym.calls == [ "open_site", "save_screenshot", "log_in", "save_screenshot", "list_class_cards", "save_screenshot", "save_text_screenshot", "save_text_screenshot", "save_text_screenshot", "save_screenshot", "save_text_screenshot", "save_text_screenshot", ] @pytest.mark.usefixtures("credentials_env") def test_main_books_the_class_and_draws_the_console_text(tmp_path: Path) -> None: gym = FakeGym(cards=[make_card("Tue, Aug 19")]) assert main(["--screenshot-dir", str(tmp_path)], lambda *_: gym) == 0 assert gym.pressed == ["123"] text, _ = gym.text_screenshots[0] assert "✓ Logged in" in text assert "✓ Successfully booked: Spin Class on Tue, Aug 19" in text @pytest.mark.usefixtures("credentials_env") def test_main_books_every_target_class_and_logs_the_report( caplog: pytest.LogCaptureFixture, ) -> None: gym = FakeGym( cards=[ make_card("Tomorrow (Thu, Aug 14)", card_id="1"), make_card("Fri, Aug 15", card_id="2"), make_card("Tue, Aug 19", card_id="3", button_text="Join Waitlist"), ] ) with caplog.at_level(logging.INFO): assert main([], lambda *_: gym) == 0 assert gym.pressed == ["1", "3"] assert "New bookings: 1\nNew waitlist entries: 1\nAlready booked/waitlisted: 0" in caplog.text assert "Total Tuesday & Thursday 6pm classes: 2" in caplog.text assert " • [New Booking] Spin Class on Tomorrow (Thu, Aug 14)" in caplog.text assert " • [New Waitlist] Spin Class on Tue, Aug 19" in caplog.text @pytest.mark.usefixtures("credentials_env") def test_main_saves_step_3_after_the_first_class_and_the_report_images(tmp_path: Path) -> None: gym = FakeGym( cards=[make_card("Thu, Aug 14", card_id="1"), make_card("Tue, Aug 19", card_id="2")] ) assert main(["--screenshot-dir", str(tmp_path)], lambda *_: gym) == 0 assert gym.calls == [ "open_site", "save_screenshot", "log_in", "save_screenshot", "list_class_cards", "press_class_button", "save_screenshot", "press_class_button", "save_text_screenshot", "save_text_screenshot", "save_text_screenshot", "open_my_bookings", "list_my_bookings", "save_screenshot", "save_text_screenshot", "save_text_screenshot", ] assert gym.screenshots[-2:] == [tmp_path / "step-3.png", tmp_path / "step-7.png"] (_, _), (step_5, _), (step_6, _), (step_8, step_8_path), (step_9, step_9_path) = ( gym.text_screenshots ) assert step_8_path == tmp_path / "step-8.png" assert step_8.startswith("--- VERIFYING ON MY BOOKINGS PAGE ---") assert step_8.endswith("✅ SUCCESS: All bookings verified!") assert step_9_path == tmp_path / "step-9.png" assert step_9.startswith("✓ Logged in") assert step_9.endswith("✅ SUCCESS: All bookings verified!") assert step_5.startswith("--- BOOKING SUMMARY ---\nNew bookings: 2") assert "--- DETAILED CLASS LIST ---" not in step_5 assert step_6.startswith(step_5) assert "--- DETAILED CLASS LIST ---\n • [New Booking] Spin Class on Thu, Aug 14" in step_6 @pytest.mark.usefixtures("credentials_env") def test_main_logs_the_verification_and_returns_0_on_success( caplog: pytest.LogCaptureFixture, ) -> None: gym = FakeGym(cards=[make_card("Tue, Aug 19")]) with caplog.at_level(logging.INFO): assert main([], lambda *_: gym) == 0 assert "--- VERIFYING ON MY BOOKINGS PAGE ---\n ✓ Verified: Spin Class" in caplog.text assert "Expected: 1 booking\nFound: 1 booking\n✅ SUCCESS" in caplog.text @pytest.mark.usefixtures("credentials_env") def test_main_notes_bookings_after_the_handled_classes_and_returns_0( caplog: pytest.LogCaptureFixture, ) -> None: gym = FakeGym( cards=[make_card("Tue, Aug 19")], bookings=[make_entry("Tue, Aug 19, 6:00 PM"), make_entry("Tue, Aug 26, 6:00 PM")], ) with caplog.at_level(logging.INFO): assert main([], lambda *_: gym) == 0 assert " i Also booked (not checked): Spin Class on Tue, Aug 26\n✅ SUCCESS" in caplog.text @pytest.mark.usefixtures("credentials_env") def test_main_returns_3_on_a_mismatch(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None: gym = FakeGym(cards=[make_card("Tue, Aug 19")], bookings=[make_entry("Wed, Aug 20, 7:00 AM")]) with caplog.at_level(logging.INFO): code = main(["--headless", "--screenshot-dir", str(tmp_path)], lambda *_: gym) assert code == 3 assert "❌ MISMATCH: Missing 1 bookings" in caplog.text assert " ✗ Missing: Spin Class on Tue, Aug 19" in caplog.text assert gym.screenshots[-1] == tmp_path / "step-7.png" assert gym.text_screenshots[-2][1] == tmp_path / "step-8.png" assert gym.text_screenshots[-1][1] == tmp_path / "step-9.png" assert gym.closed is True @pytest.mark.usefixtures("credentials_env") def test_main_returns_1_when_the_bookings_page_is_empty() -> None: gym = FakeGym(cards=[make_card("Tue, Aug 19")], bookings=[]) assert main([], lambda *_: gym, no_sleep) == 1 @pytest.mark.usefixtures("credentials_env") def test_main_logs_an_empty_report_without_target_classes( caplog: pytest.LogCaptureFixture, ) -> None: gym = FakeGym(cards=[make_card("Wed, Aug 13")]) with caplog.at_level(logging.INFO): assert main([], lambda *_: gym) == 0 assert "Total Tuesday & Thursday 6pm classes: 0" in caplog.text assert "No matching class found" in caplog.text @pytest.mark.usefixtures("credentials_env") def test_main_first_tuesday_only_books_one_class_and_no_report( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: gym = FakeGym( cards=[make_card("Thu, Aug 14", card_id="1"), make_card("Tue, Aug 19", card_id="2")] ) with caplog.at_level(logging.INFO): code = main(["--first-tuesday-only", "--screenshot-dir", str(tmp_path)], lambda *_: gym) assert code == 0 assert gym.pressed == ["2"] assert "BOOKING SUMMARY" not in caplog.text assert "VERIFYING" not in caplog.text assert "open_my_bookings" not in gym.calls assert [path.name for path in gym.screenshots] == ["step-1.png", "step-2.png", "step-3.png"] assert [path.name for _, path in gym.text_screenshots] == ["step-4.png"] @pytest.mark.usefixtures("credentials_env") def test_first_tuesday_only_flag_is_read() -> None: assert parse_args(["--first-tuesday-only"]).first_tuesday_only is True assert parse_args([]).first_tuesday_only is False @pytest.mark.usefixtures("credentials_env") def test_main_returns_1_when_the_booking_fails() -> None: gym = FakeGym(cards=[make_card("Tue, Aug 19")], press_error="Network down") assert main(["--headless"], lambda *_: gym, no_sleep) == 1 assert gym.closed is True @pytest.mark.usefixtures("credentials_env") def test_main_saves_no_screenshot_without_directory() -> None: gym = FakeGym() assert main([], lambda *_: gym) == 0 assert gym.screenshots == [] def test_main_returns_2_when_credentials_are_missing( monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ) -> None: monkeypatch.delenv(constants.ACCOUNT_EMAIL_ENV, raising=False) monkeypatch.delenv(constants.ACCOUNT_PASSWORD_ENV, raising=False) gym = FakeGym() code = main([], lambda *_: gym) assert code == 2 assert gym.calls == [] assert constants.ACCOUNT_EMAIL_ENV in caplog.text @pytest.mark.usefixtures("credentials_env") def test_main_returns_1_when_login_fails() -> None: gym = FakeGym(login_error="Invalid credentials") assert main(["--headless"], lambda *_: gym, no_sleep) == 1 assert gym.closed is True @pytest.mark.usefixtures("credentials_env") def test_main_closes_the_gym_when_not_kept_open() -> None: gym = FakeGym() assert main(["--headless"], lambda *_: gym) == 0 assert gym.closed is True @pytest.mark.usefixtures("credentials_env") def test_main_keeps_the_gym_open_when_asked() -> None: gym = FakeGym() assert main([], lambda *_: gym) == 0 assert gym.closed is False @pytest.mark.usefixtures("credentials_env") def test_main_passes_options_to_the_factory(tmp_path: Path) -> None: received: list[tuple[bool, Path, bool]] = [] def factory(headless: bool, profile_dir: Path, keep_open: bool) -> FakeGym: received.append((headless, profile_dir, keep_open)) return FakeGym() main(["--headless", "--profile-dir", str(tmp_path)], factory) assert received == [(True, tmp_path, False)] def _write_env_file(path: Path, email: str, password: str) -> Path: path.write_text( f"{constants.ACCOUNT_EMAIL_ENV}={email}\n" f"export {constants.ACCOUNT_PASSWORD_ENV}='{password}'\n", encoding="utf-8", ) return path def test_env_file_defaults_to_the_gym_file_name() -> None: assert parse_args([]).env_file == Path(constants.ENV_FILE_NAME) def test_env_file_option_is_read(tmp_path: Path) -> None: assert parse_args(["--env-file", str(tmp_path / "x.env")]).env_file == tmp_path / "x.env" def test_main_reads_the_account_from_the_env_file( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.delenv(constants.ACCOUNT_EMAIL_ENV, raising=False) monkeypatch.delenv(constants.ACCOUNT_PASSWORD_ENV, raising=False) env_file = _write_env_file(tmp_path / "accounts.env", "file@example.invalid", "file-pass") gym = FakeGym() code = main(["--env-file", str(env_file)], lambda *_: gym) assert code == 0 assert gym.logins == [("file@example.invalid", "file-pass")] def test_main_lets_a_real_environment_variable_win_over_the_env_file( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setenv(constants.ACCOUNT_EMAIL_ENV, DUMMY_EMAIL) monkeypatch.setenv(constants.ACCOUNT_PASSWORD_ENV, DUMMY_PASSWORD) env_file = _write_env_file(tmp_path / "accounts.env", "file@example.invalid", "file-pass") gym = FakeGym() code = main(["--env-file", str(env_file)], lambda *_: gym) assert code == 0 assert gym.logins == [(DUMMY_EMAIL, DUMMY_PASSWORD)] def test_main_exits_2_when_neither_environment_nor_file_has_the_account( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.delenv(constants.ACCOUNT_EMAIL_ENV, raising=False) monkeypatch.delenv(constants.ACCOUNT_PASSWORD_ENV, raising=False) assert main(["--env-file", str(tmp_path / "absent.env")], lambda *_: FakeGym()) == 2 def test_main_exits_2_when_the_admin_account_is_half_set( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setenv(constants.ACCOUNT_EMAIL_ENV, DUMMY_EMAIL) monkeypatch.setenv(constants.ACCOUNT_PASSWORD_ENV, DUMMY_PASSWORD) monkeypatch.setenv(constants.ADMIN_EMAIL_ENV, "admin@example.invalid") monkeypatch.delenv(constants.ADMIN_PASSWORD_ENV, raising=False) assert main(["--env-file", str(tmp_path / "absent.env")], lambda *_: FakeGym()) == 2