MIL-003: profile, navigation and automated login #42

Merged
Tirsvad merged 2 commits from mil-003-login into main 2026-10-28 10:42:09 +01:00
23 changed files with 789 additions and 39 deletions
Showing only changes of commit 2fa9e15ab5 - Show all commits
+1 -1
View File
@@ -8,7 +8,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
SNACK_AND_LIFT_E2E=1 SNACK_AND_LIFT_E2E=1
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends chromium chromium-driver fonts-liberation \ && apt-get install -y --no-install-recommends chromium chromium-driver fonts-liberation fonts-noto-color-emoji \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
+18
View File
@@ -75,6 +75,24 @@ Leave the environment with `deactivate`. The folder `.venv` is ignored by git.
## ▶️ Run ## ▶️ Run
### Your gym account
The bot never contains a password. Register your own account once, by hand, as the lecture does: run the bot with `--keep-open` so Chrome stays open (the `chrome_profile` folder keeps the site's data between runs), click "Join Today... Or Tomorrow" and register with an e-mail such as `yourname@test.com` and a password you remember (not a trivial one such as `12346`, Chrome warns about those). Then give the bot the account through two environment variables:
```bash
# Linux and macOS
export SNACK_AND_LIFT_EMAIL="yourname@test.com"
export SNACK_AND_LIFT_PASSWORD="your password"
```
```powershell
# Windows PowerShell
$env:SNACK_AND_LIFT_EMAIL = "yourname@test.com"
$env:SNACK_AND_LIFT_PASSWORD = "your password"
```
### Start the bot
With the environment active: With the environment active:
```bash ```bash
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

+1 -1
View File
@@ -29,7 +29,7 @@ where = ["src"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
testpaths = ["tests"] testpaths = ["tests"]
addopts = "-ra --strict-markers" addopts = "-ra --strict-markers"
pythonpath = ["src"] pythonpath = ["src", "."]
markers = [ markers = [
"browser: needs a real browser (run in the Docker container)", "browser: needs a real browser (run in the Docker container)",
] ]
@@ -0,0 +1,35 @@
"""@file credentials.py
@brief The account the bot logs in with, read from the environment (never from the code).
"""
from collections.abc import Mapping
from dataclasses import dataclass, field
from snack_and_lift import constants
from snack_and_lift.domain.errors import MissingCredentialsError
@dataclass(frozen=True, slots=True)
class Credentials:
"""@brief E-mail and password of the gym account; the password is never printed."""
email: str
password: str = field(repr=False)
def load_credentials(environment: Mapping[str, str]) -> Credentials:
"""@brief Read the account from the environment.
@param environment The environment variables, for example `os.environ`.
@return The credentials.
@exception MissingCredentialsError A variable is missing or empty; the message names
the variables and never a value.
"""
email = environment.get(constants.ACCOUNT_EMAIL_ENV, "").strip()
password = environment.get(constants.ACCOUNT_PASSWORD_ENV, "")
if not email or not password:
raise MissingCredentialsError(
f"Set {constants.ACCOUNT_EMAIL_ENV} and {constants.ACCOUNT_PASSWORD_ENV} "
"in the environment (they must not be empty)"
)
return Credentials(email=email, password=password)
+38
View File
@@ -0,0 +1,38 @@
"""@file ports.py
@brief The ports the use cases need from the outside world.
"""
from pathlib import Path
from typing import Protocol
class GymPort(Protocol):
"""@brief The gym site as the use cases see it."""
def open_site(self) -> None:
"""@brief Open the home page and wait until it is ready.
@exception GymError The page did not load.
"""
...
def log_in(self, email: str, password: str) -> None:
"""@brief Log in and wait until the class schedule is shown.
@param email The account e-mail.
@param password The account password.
@exception LoginFailedError The site refused the login.
@exception GymError The page did not behave as expected.
"""
...
def save_screenshot(self, path: Path) -> None:
"""@brief Save a screenshot of the current page.
@param path The PNG file to write; its folders are created.
"""
...
def close(self) -> None:
"""@brief Close the browser."""
...
+21
View File
@@ -0,0 +1,21 @@
"""@file session.py
@brief Lecture step 2: the login of the bot.
"""
import logging
from snack_and_lift.application.credentials import Credentials
from snack_and_lift.application.ports import GymPort
logger = logging.getLogger(__name__)
def login(gym: GymPort, credentials: Credentials) -> None:
"""@brief Log in and wait until the class schedule is shown.
@param gym The gym site.
@param credentials The account to log in with.
@exception LoginFailedError The login failed; `retry` repeats it (lecture step 9).
"""
gym.log_in(credentials.email, credentials.password)
logger.info("✓ Logged in: the class schedule is shown")
+94 -8
View File
@@ -4,16 +4,40 @@
import argparse import argparse
import logging import logging
from collections.abc import Sequence import os
import sys
from collections.abc import Callable, Sequence
from pathlib import Path
from snack_and_lift.application.credentials import load_credentials
from snack_and_lift.application.ports import GymPort
from snack_and_lift.application.session import login
from snack_and_lift.domain.errors import LoginFailedError, MissingCredentialsError
from snack_and_lift.infrastructure.browser import create_driver, default_profile_dir
from snack_and_lift.infrastructure.selenium_gym import SeleniumGym
## Builds the gym site from (headless, profile folder, keep open).
GymFactory = Callable[[bool, Path, bool], GymPort]
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def selenium_gym_factory(headless: bool, profile_dir: Path, keep_open: bool) -> GymPort:
"""@brief Start Chrome and wrap it as the gym site.
@param headless True to run without a window.
@param profile_dir The Chrome user data folder.
@param keep_open True to leave the browser open when the program ends.
@return The gym site.
"""
return SeleniumGym(create_driver(headless, profile_dir, keep_open))
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
"""@brief Read the command line. """@brief Read the command line.
@param argv Arguments without the program name; None reads `sys.argv`. @param argv Arguments without the program name; None reads `sys.argv`.
@return The parsed options. @return The parsed options; `keep_open` is True unless `--headless` is given.
""" """
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
prog="snack-and-lift", prog="snack-and-lift",
@@ -24,16 +48,78 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
action="store_true", action="store_true",
help="run Chrome without a window (the Docker image does this)", help="run Chrome without a window (the Docker image does this)",
) )
return parser.parse_args(argv) parser.add_argument(
"--keep-open",
action="store_true",
default=None,
help="leave the browser open at the end (default when not headless)",
)
parser.add_argument(
"--profile-dir",
type=Path,
default=None,
help="Chrome profile folder (default: ./chrome_profile)",
)
parser.add_argument(
"--screenshot-dir",
type=Path,
default=None,
help="save a screenshot after each step into this folder",
)
args = parser.parse_args(argv)
if args.keep_open is None:
args.keep_open = not args.headless
if args.profile_dir is None:
args.profile_dir = default_profile_dir()
return args
def main(argv: Sequence[str] | None = None) -> int: def _use_utf8_output() -> None:
"""@brief Start the bot. """@brief Make stdout UTF-8 so the check mark of the messages prints on Windows."""
reconfigure = getattr(sys.stdout, "reconfigure", None)
if reconfigure is not None:
reconfigure(encoding="utf-8")
def _save_step(gym: GymPort, screenshot_dir: Path | None, name: str) -> None:
"""@brief Save a screenshot when a folder was asked for.
@param gym The gym site.
@param screenshot_dir The folder, or None for no screenshots.
@param name The file name.
"""
if screenshot_dir is not None:
gym.save_screenshot(screenshot_dir / name)
def main(
argv: Sequence[str] | None = None,
gym_factory: GymFactory = selenium_gym_factory,
) -> int:
"""@brief Start the bot: open the site and log in.
@param argv Arguments without the program name; None reads `sys.argv`. @param argv Arguments without the program name; None reads `sys.argv`.
@return Process exit code. @param gym_factory Builds the gym site; tests pass a fake.
@return Process exit code: 0 done, 1 login failed, 2 credentials missing.
""" """
logging.basicConfig(level=logging.INFO, format="%(message)s") logging.basicConfig(level=logging.INFO, format="%(message)s")
parse_args(argv) _use_utf8_output()
logger.info("snack-and-lift: the lecture steps are added by the next milestones") args = parse_args(argv)
try:
credentials = load_credentials(os.environ)
except MissingCredentialsError as error:
logger.error("%s", error)
return 2
gym = gym_factory(args.headless, args.profile_dir, args.keep_open)
try:
gym.open_site()
_save_step(gym, args.screenshot_dir, "step-1.png")
login(gym, credentials)
_save_step(gym, args.screenshot_dir, "step-2.png")
except LoginFailedError as error:
logger.error("Login failed: %s", error)
return 1
finally:
if not args.keep_open:
gym.close()
return 0 return 0
+21
View File
@@ -24,6 +24,27 @@ TARGET_WEEKDAYS: Final[tuple[str, ...]] = ("Tue", "Thu")
## Id of the login button in the navigation bar of the site (the home page has no title). ## Id of the login button in the navigation bar of the site (the home page has no title).
NAV_LOGIN_BUTTON_ID: Final[str] = "login-button" NAV_LOGIN_BUTTON_ID: Final[str] = "login-button"
## Id of the e-mail field of the login form.
LOGIN_EMAIL_INPUT_ID: Final[str] = "email-input"
## Id of the password field of the login form.
LOGIN_PASSWORD_INPUT_ID: Final[str] = "password-input"
## Id of the submit button of the login form.
LOGIN_SUBMIT_BUTTON_ID: Final[str] = "submit-button"
## Id of the element that shows a login error.
ERROR_MESSAGE_ID: Final[str] = "error-message"
## Id of the class schedule page, shown after a successful login.
SCHEDULE_PAGE_ID: Final[str] = "schedule-page"
## Name of the environment variable that holds the account e-mail.
ACCOUNT_EMAIL_ENV: Final[str] = "SNACK_AND_LIFT_EMAIL"
## Name of the environment variable that holds the account password.
ACCOUNT_PASSWORD_ENV: Final[str] = "SNACK_AND_LIFT_PASSWORD"
## Folder where screenshots of a container run are written. ## Folder where screenshots of a container run are written.
SCREENSHOT_DIR: Final[str] = "docs/images" SCREENSHOT_DIR: Final[str] = "docs/images"
+15
View File
@@ -0,0 +1,15 @@
"""@file errors.py
@brief The errors of the bot.
"""
class GymError(Exception):
"""@brief The gym site did not behave as the bot expected (a page, element or timeout)."""
class LoginFailedError(GymError):
"""@brief The site refused the login; the message is the text the site shows."""
class MissingCredentialsError(Exception):
"""@brief The account e-mail or password is not set in the environment."""
+33 -6
View File
@@ -1,8 +1,9 @@
"""@file browser.py """@file browser.py
@brief Creates the Chrome driver, headless when asked. @brief Creates the Chrome driver with a persistent profile, headless when asked.
""" """
import os import os
from pathlib import Path
from selenium import webdriver from selenium import webdriver
from selenium.webdriver.chrome.options import Options from selenium.webdriver.chrome.options import Options
@@ -11,23 +12,49 @@ from selenium.webdriver.chrome.service import Service
from snack_and_lift import constants from snack_and_lift import constants
def create_driver(headless: bool) -> webdriver.Chrome: def default_profile_dir() -> Path:
"""@brief Start Chrome. """@brief The folder where Chrome keeps the profile of the bot.
The binary and the driver come from `CHROME_BIN` and `CHROMEDRIVER_PATH` when @return `chrome_profile` below the working directory.
these are set (the Docker image sets them); otherwise Selenium finds them. """
return Path.cwd() / constants.PROFILE_DIR_NAME
def build_options(headless: bool, profile_dir: Path, keep_open: bool) -> Options:
"""@brief Build the Chrome options (no browser is started).
@param headless True to run without a window, as in the Docker container. @param headless True to run without a window, as in the Docker container.
@return A running Chrome driver. @param profile_dir The Chrome user data folder.
@param keep_open True to leave the browser open when the program ends.
@return The options.
""" """
options = Options() options = Options()
options.add_argument(f"--user-data-dir={profile_dir}")
options.add_argument(f"--window-size={constants.WINDOW_SIZE}") options.add_argument(f"--window-size={constants.WINDOW_SIZE}")
if keep_open:
options.add_experimental_option("detach", True)
if headless: if headless:
for argument in constants.HEADLESS_BROWSER_ARGUMENTS: for argument in constants.HEADLESS_BROWSER_ARGUMENTS:
options.add_argument(argument) options.add_argument(argument)
binary = os.environ.get(constants.CHROME_BIN_ENV) binary = os.environ.get(constants.CHROME_BIN_ENV)
if binary: if binary:
options.binary_location = binary options.binary_location = binary
return options
def create_driver(headless: bool, profile_dir: Path, keep_open: bool) -> webdriver.Chrome:
"""@brief Start Chrome.
The binary and the driver come from `CHROME_BIN` and `CHROMEDRIVER_PATH` when
these are set (the Docker image sets them); otherwise Selenium finds them.
@param headless True to run without a window, as in the Docker container.
@param profile_dir The Chrome user data folder; it is created when missing.
@param keep_open True to leave the browser open when the program ends.
@return A running Chrome driver.
"""
profile_dir.mkdir(parents=True, exist_ok=True)
options = build_options(headless, profile_dir, keep_open)
driver_path = os.environ.get(constants.CHROMEDRIVER_PATH_ENV) driver_path = os.environ.get(constants.CHROMEDRIVER_PATH_ENV)
service = Service(executable_path=driver_path) if driver_path else Service() service = Service(executable_path=driver_path) if driver_path else Service()
return webdriver.Chrome(service=service, options=options) return webdriver.Chrome(service=service, options=options)
@@ -0,0 +1,111 @@
"""@file selenium_gym.py
@brief The Selenium adapter of the GymPort.
"""
import functools
import logging
from collections.abc import Callable
from pathlib import Path
from typing import Concatenate
from selenium.common.exceptions import (
ElementClickInterceptedException,
NoSuchElementException,
StaleElementReferenceException,
TimeoutException,
)
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver
from selenium.webdriver.support import expected_conditions as ec
from selenium.webdriver.support.ui import WebDriverWait
from snack_and_lift import constants
from snack_and_lift.domain.errors import GymError, LoginFailedError
logger = logging.getLogger(__name__)
_SELENIUM_ERRORS = (
TimeoutException,
NoSuchElementException,
StaleElementReferenceException,
ElementClickInterceptedException,
)
def _translate_errors[S, **P, R](
method: Callable[Concatenate[S, P], R],
) -> Callable[Concatenate[S, P], R]:
"""@brief Turn Selenium errors of a method into GymError.
@param method The adapter method to wrap.
@return The wrapped method.
"""
@functools.wraps(method)
def wrapper(self: S, *args: P.args, **kwargs: P.kwargs) -> R:
try:
return method(self, *args, **kwargs)
except _SELENIUM_ERRORS as error:
raise GymError(f"{method.__name__} failed: {type(error).__name__}") from error
return wrapper
class SeleniumGym:
"""@brief The gym site driven by a Selenium WebDriver."""
def __init__(self, driver: WebDriver, base_url: str = constants.GYM_URL) -> None:
"""@brief Wrap a driver.
@param driver A running WebDriver.
@param base_url The address of the home page.
"""
self._driver = driver
self._base_url = base_url
self._wait = WebDriverWait(driver, constants.WAIT_TIMEOUT_SECONDS)
@_translate_errors
def open_site(self) -> None:
"""@brief Open the home page and wait for the login button.
@exception GymError The page did not load.
"""
self._driver.get(self._base_url)
self._wait.until(ec.presence_of_element_located((By.ID, constants.NAV_LOGIN_BUTTON_ID)))
@_translate_errors
def log_in(self, email: str, password: str) -> None:
"""@brief Fill in the login form and wait for the schedule or an error.
@param email The account e-mail.
@param password The account password.
@exception LoginFailedError The site shows a login error.
@exception GymError The page did not behave as expected.
"""
self._wait.until(ec.element_to_be_clickable((By.ID, constants.NAV_LOGIN_BUTTON_ID))).click()
self._wait.until(
ec.presence_of_element_located((By.ID, constants.LOGIN_EMAIL_INPUT_ID))
).send_keys(email)
self._driver.find_element(By.ID, constants.LOGIN_PASSWORD_INPUT_ID).send_keys(password)
self._driver.find_element(By.ID, constants.LOGIN_SUBMIT_BUTTON_ID).click()
self._wait.until(
ec.any_of(
ec.presence_of_element_located((By.ID, constants.SCHEDULE_PAGE_ID)),
ec.presence_of_element_located((By.ID, constants.ERROR_MESSAGE_ID)),
)
)
errors = self._driver.find_elements(By.ID, constants.ERROR_MESSAGE_ID)
if errors:
raise LoginFailedError(errors[0].text)
def save_screenshot(self, path: Path) -> None:
"""@brief Save a screenshot of the current page.
@param path The PNG file to write; its folders are created.
"""
path.parent.mkdir(parents=True, exist_ok=True)
self._driver.save_screenshot(str(path))
def close(self) -> None:
"""@brief Quit the browser."""
self._driver.quit()
+7
View File
@@ -0,0 +1,7 @@
<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><title>Fixture gym home</title></head>
<body>
<nav><a href="login.html"><button id="login-button">Login</button></a></nav>
</body>
</html>
+28
View File
@@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><title>Fixture gym login</title></head>
<body>
<form id="login-form">
<input id="email-input" type="email">
<input id="password-input" type="password">
<button id="submit-button" type="submit">Log in</button>
</form>
<div id="message-area"></div>
<script>
// Dummy account of this fixture only; it is not a real account.
const ACCEPTED_EMAIL = "fixture@example.invalid";
const ACCEPTED_PASSWORD = "fixture-password";
document.getElementById("login-form").addEventListener("submit", function (event) {
event.preventDefault();
const email = document.getElementById("email-input").value;
const password = document.getElementById("password-input").value;
if (email === ACCEPTED_EMAIL && password === ACCEPTED_PASSWORD) {
window.location.href = "schedule.html";
} else {
document.getElementById("message-area").innerHTML =
'<div id="error-message">Invalid email or password</div>';
}
});
</script>
</body>
</html>
+7
View File
@@ -0,0 +1,7 @@
<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><title>Fixture gym schedule</title></head>
<body>
<div id="schedule-page">Class schedule</div>
</body>
</html>
+3 -1
View File
@@ -3,6 +3,8 @@
""" """
import os import os
import tempfile
from pathlib import Path
import pytest import pytest
from selenium.webdriver.common.by import By from selenium.webdriver.common.by import By
@@ -22,7 +24,7 @@ pytestmark = [
def test_gym_home_page_shows_the_login_button() -> None: def test_gym_home_page_shows_the_login_button() -> None:
driver = create_driver(headless=True) driver = create_driver(headless=True, profile_dir=Path(tempfile.mkdtemp()), keep_open=False)
try: try:
driver.get(constants.GYM_URL) driver.get(constants.GYM_URL)
button = WebDriverWait(driver, constants.WAIT_TIMEOUT_SECONDS).until( button = WebDriverWait(driver, constants.WAIT_TIMEOUT_SECONDS).until(
+57
View File
@@ -0,0 +1,57 @@
"""@file test_selenium_gym.py
@brief The Selenium adapter against local HTML fixtures; runs only in the Docker container.
"""
import os
from collections.abc import Iterator
from pathlib import Path
import pytest
from snack_and_lift.domain.errors import LoginFailedError
from snack_and_lift.infrastructure.browser import create_driver
from snack_and_lift.infrastructure.selenium_gym import SeleniumGym
pytestmark = [
pytest.mark.browser,
pytest.mark.skipif(
os.environ.get("SNACK_AND_LIFT_E2E") != "1",
reason="needs a real browser; run it in the Docker container",
),
]
FIXTURE_HOME = (Path(__file__).parent / "fixtures" / "home.html").as_uri()
# Dummy account that the fixture login.html accepts; it is not a real account.
FIXTURE_EMAIL = "fixture@example.invalid"
FIXTURE_PASSWORD = "fixture-password"
@pytest.fixture
def gym(tmp_path: Path) -> Iterator[SeleniumGym]:
driver = create_driver(headless=True, profile_dir=tmp_path / "profile", keep_open=False)
try:
yield SeleniumGym(driver, base_url=FIXTURE_HOME)
finally:
driver.quit()
def test_log_in_succeeds_with_accepted_credentials(gym: SeleniumGym) -> None:
gym.open_site()
gym.log_in(FIXTURE_EMAIL, FIXTURE_PASSWORD)
def test_log_in_raises_for_wrong_credentials(gym: SeleniumGym) -> None:
gym.open_site()
with pytest.raises(LoginFailedError, match="Invalid"):
gym.log_in(FIXTURE_EMAIL, "wrong-password")
def test_save_screenshot_creates_the_folder(gym: SeleniumGym, tmp_path: Path) -> None:
gym.open_site()
target = tmp_path / "shots" / "step-1.png"
gym.save_screenshot(target)
assert target.stat().st_size > 0
+35
View File
@@ -0,0 +1,35 @@
"""@file fakes.py
@brief A fake of the GymPort for the unit tests.
"""
from pathlib import Path
from snack_and_lift.domain.errors import LoginFailedError
class FakeGym:
"""@brief Records the calls; can be told to refuse the login."""
def __init__(self, login_error: str | None = None) -> None:
self.login_error = login_error
self.calls: list[str] = []
self.logins: list[tuple[str, str]] = []
self.screenshots: list[Path] = []
self.closed = False
def open_site(self) -> None:
self.calls.append("open_site")
def log_in(self, email: str, password: str) -> None:
self.calls.append("log_in")
self.logins.append((email, password))
if self.login_error is not None:
raise LoginFailedError(self.login_error)
def save_screenshot(self, path: Path) -> None:
self.calls.append("save_screenshot")
self.screenshots.append(path)
def close(self) -> None:
self.calls.append("close")
self.closed = True
+50
View File
@@ -0,0 +1,50 @@
"""@file test_browser_options.py
@brief The Chrome options are built correctly (no browser is started).
"""
from pathlib import Path
import pytest
from snack_and_lift import constants
from snack_and_lift.infrastructure.browser import build_options, default_profile_dir
def test_profile_dir_is_passed_to_chrome(tmp_path: Path) -> None:
options = build_options(False, tmp_path, False)
assert f"--user-data-dir={tmp_path}" in options.arguments
def test_default_profile_dir_is_below_the_working_directory() -> None:
assert default_profile_dir() == Path.cwd() / constants.PROFILE_DIR_NAME
def test_keep_open_detaches_the_browser(tmp_path: Path) -> None:
assert build_options(False, tmp_path, True).experimental_options["detach"] is True
def test_browser_is_not_detached_when_not_kept_open(tmp_path: Path) -> None:
assert "detach" not in build_options(False, tmp_path, False).experimental_options
def test_headless_adds_the_headless_arguments(tmp_path: Path) -> None:
options = build_options(True, tmp_path, False)
for argument in constants.HEADLESS_BROWSER_ARGUMENTS:
assert argument in options.arguments
def test_window_has_no_headless_arguments_by_default(tmp_path: Path) -> None:
options = build_options(False, tmp_path, False)
assert "--headless=new" not in options.arguments
assert f"--window-size={constants.WINDOW_SIZE}" in options.arguments
def test_chrome_binary_comes_from_the_environment(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.setenv(constants.CHROME_BIN_ENV, "/opt/chrome")
assert build_options(False, tmp_path, False).binary_location == "/opt/chrome"
+110 -3
View File
@@ -1,8 +1,24 @@
"""@file test_cli.py """@file test_cli.py
@brief The command line accepts its options and the entry point returns 0. @brief The command line accepts its options and the entry point wires the use case.
""" """
from pathlib import Path
import pytest
from tests.unit.fakes import FakeGym
from snack_and_lift import constants
from snack_and_lift.cli import main, parse_args 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
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: def test_headless_is_off_by_default() -> None:
@@ -13,5 +29,96 @@ def test_headless_flag_is_read() -> None:
assert parse_args(["--headless"]).headless is True assert parse_args(["--headless"]).headless is True
def test_main_returns_zero() -> None: def test_browser_stays_open_by_default_when_not_headless() -> None:
assert main([]) == 0 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"]
assert gym.calls == ["open_site", "save_screenshot", "log_in", "save_screenshot"]
@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) == 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)]
+14
View File
@@ -9,6 +9,20 @@ def test_gym_address_is_https() -> None:
assert constants.GYM_URL.startswith("https://") assert constants.GYM_URL.startswith("https://")
def test_credentials_are_read_from_named_environment_variables() -> None:
assert constants.ACCOUNT_EMAIL_ENV == "SNACK_AND_LIFT_EMAIL"
assert constants.ACCOUNT_PASSWORD_ENV == "SNACK_AND_LIFT_PASSWORD"
def test_login_element_ids() -> None:
assert constants.NAV_LOGIN_BUTTON_ID == "login-button"
assert constants.LOGIN_EMAIL_INPUT_ID == "email-input"
assert constants.LOGIN_PASSWORD_INPUT_ID == "password-input"
assert constants.LOGIN_SUBMIT_BUTTON_ID == "submit-button"
assert constants.ERROR_MESSAGE_ID == "error-message"
assert constants.SCHEDULE_PAGE_ID == "schedule-page"
def test_retry_count_is_seven() -> None: def test_retry_count_is_seven() -> None:
assert constants.RETRY_COUNT == 7 assert constants.RETRY_COUNT == 7
+44
View File
@@ -0,0 +1,44 @@
"""@file test_credentials.py
@brief Credentials come from the environment and never show the password.
"""
import pytest
from snack_and_lift import constants
from snack_and_lift.application.credentials import Credentials, load_credentials
from snack_and_lift.domain.errors import MissingCredentialsError
DUMMY_PASSWORD = "dummy-secret-value"
def test_load_credentials_reads_both_variables() -> None:
environment = {
constants.ACCOUNT_EMAIL_ENV: "dummy@example.invalid",
constants.ACCOUNT_PASSWORD_ENV: DUMMY_PASSWORD,
}
assert load_credentials(environment) == Credentials("dummy@example.invalid", DUMMY_PASSWORD)
@pytest.mark.parametrize(
"environment",
[
{},
{constants.ACCOUNT_EMAIL_ENV: "dummy@example.invalid"},
{constants.ACCOUNT_PASSWORD_ENV: DUMMY_PASSWORD},
{constants.ACCOUNT_EMAIL_ENV: "", constants.ACCOUNT_PASSWORD_ENV: DUMMY_PASSWORD},
{constants.ACCOUNT_EMAIL_ENV: "dummy@example.invalid", constants.ACCOUNT_PASSWORD_ENV: ""},
],
)
def test_load_credentials_rejects_missing_or_empty(environment: dict[str, str]) -> None:
with pytest.raises(MissingCredentialsError) as caught:
load_credentials(environment)
message = str(caught.value)
assert constants.ACCOUNT_EMAIL_ENV in message
assert constants.ACCOUNT_PASSWORD_ENV in message
assert DUMMY_PASSWORD not in message
def test_repr_hides_the_password() -> None:
assert DUMMY_PASSWORD not in repr(Credentials("dummy@example.invalid", DUMMY_PASSWORD))
+27
View File
@@ -0,0 +1,27 @@
"""@file test_session.py
@brief The login use case hands the credentials to the gym site.
"""
import pytest
from tests.unit.fakes import FakeGym
from snack_and_lift.application.credentials import Credentials
from snack_and_lift.application.session import login
from snack_and_lift.domain.errors import LoginFailedError
CREDENTIALS = Credentials("dummy@example.invalid", "dummy-password")
def test_login_passes_the_credentials_to_the_gym() -> None:
gym = FakeGym()
login(gym, CREDENTIALS)
assert gym.logins == [("dummy@example.invalid", "dummy-password")]
def test_login_propagates_a_refused_login() -> None:
gym = FakeGym(login_error="Invalid credentials")
with pytest.raises(LoginFailedError, match="Invalid credentials"):
login(gym, CREDENTIALS)