diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a5571f2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.git +.venv +.env +__pycache__ +*.pyc +.pytest_cache +docs/doxygen +framework +.claude +.agents diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..429f195 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +# Headless Chromium for real-browser runs and screenshots (ADR-0003). +FROM python:3.13-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + CHROME_BIN=/usr/bin/chromium \ + CHROMEDRIVER_PATH=/usr/bin/chromedriver \ + SNACK_AND_LIFT_E2E=1 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends chromium chromium-driver fonts-liberation \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +COPY pyproject.toml README.md LICENSE ./ +COPY src ./src +RUN python -m pip install --upgrade pip && python -m pip install -e ".[dev]" +COPY tests ./tests + +ENTRYPOINT ["python", "-m", "snack_and_lift"] diff --git a/Doxyfile b/Doxyfile new file mode 100644 index 0000000..457936c --- /dev/null +++ b/Doxyfile @@ -0,0 +1,16 @@ +# Doxygen configuration for the Snack and Lift booking bot. Run: doxygen Doxyfile +PROJECT_NAME = "Snack and Lift Booking Bot" +PROJECT_BRIEF = "Selenium bot that books classes at the Snack and Lift practice gym (Udemy 100 Days of Code, day 49)" +OUTPUT_DIRECTORY = docs/doxygen +INPUT = src +FILE_PATTERNS = *.py +RECURSIVE = YES +EXTRACT_ALL = YES +OPTIMIZE_OUTPUT_JAVA = YES +PYTHON_DOCSTRING = YES +AUTOLINK_SUPPORT = YES +GENERATE_HTML = YES +GENERATE_LATEX = NO +QUIET = YES +WARN_IF_UNDOCUMENTED = YES +WARN_AS_ERROR = NO diff --git a/README.md b/README.md index 53d5107..d303dca 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,154 @@ -# 049-snack-and-lift +# ๐Ÿ‹๏ธ Snack and Lift Booking Bot +A Selenium bot that books classes at the fictional gym "Snack and Lift" for you: it logs in, books every Tuesday and Thursday 6pm class, joins the waitlist when a class is full, verifies the result on the "My Bookings" page and survives a site that fails half of its requests. It is the solution to day 49 of Udemy's *100 Days of Code: The Complete Python Pro Bootcamp*, written for course participants who want to compare solutions and for visitors who browse for ideas. + +## ๐Ÿ“š Table of Contents + +- [Overview](#-overview) +- [Requirements](#-requirements) +- [Setup](#-setup) +- [Run](#-run) +- [Tests](#-tests) +- [License](#-license) +- [Links](#-links) +- [Appendix: Project documents](#-appendix-project-documents) + +## ๐Ÿงญ Overview + +The bot follows the nine steps of the lecture on the practice site [Snack and Lift][gym]: + +1. Setup, Chrome profile and basic navigation. +2. Automated login. +3. Book the upcoming Tuesday class. +4. Check if a class is already booked. +5. Add counters and a summary. +6. Book every Tuesday and Thursday class. +7. Verify the classes on the "My Bookings" page. +8. Time travel quality assurance. +9. Add resilience against network failures (`retry`, `login`, `book_class`, `get_my_bookings`). + +The repository is organised like this: + +| Folder or file | What it is for | +| --- | --- | +| `src/snack_and_lift/` | The bot. `domain/` holds the rules with no Selenium, `application/` holds `retry`, `login`, `book_class` and `get_my_bookings`, `infrastructure/` drives the browser, `constants.py` holds every constant | +| `tests/` | `pytest` tests; the unit tests need no browser | +| `docs/` | Project documents, wiki pages (English, Danish, Bahasa Malaysia) and the screenshots of the lecture steps | +| `Dockerfile` | A Linux image with headless Chromium for real-browser runs and screenshots | +| `Doxyfile` | Doxygen configuration for the source comments | + +## ๐Ÿ“‹ Requirements + +- Python 3.13 or newer. +- Selenium (the only runtime dependency; it is installed by `pip` and finds the browser driver itself). +- Google Chrome or Chromium to run the bot on your own machine. +- `pytest` and `pytest-cov` for the tests (development extra only). +- Docker, only if you want to run the bot or take screenshots without opening a window on your desktop. +- Doxygen, only to build the source documentation. + +## ๐Ÿ› ๏ธ Setup + +Create a local virtual environment, upgrade `pip` and install the project with the test tools: + +```bash +python -m venv .venv +``` + +Activate it: + +```bash +# Linux and macOS +source .venv/bin/activate +``` + +```powershell +# Windows PowerShell +.venv\Scripts\Activate.ps1 +``` + +```bash +python -m pip install --upgrade pip +python -m pip install -e ".[dev]" +``` + +Leave the environment with `deactivate`. The folder `.venv` is ignored by git. + +## โ–ถ๏ธ Run + +With the environment active: + +```bash +snack-and-lift +``` + +or, without installing the command: + +```bash +python -m snack_and_lift +``` + +To keep your desktop free, run it in the Docker container (headless Chromium): + +```bash +docker build -t snack-and-lift . +docker run --rm snack-and-lift +``` + +## ๐Ÿงช Tests + +```bash +python -m pytest +``` + +Coverage of the logic modules is checked with `python -m pytest --cov`. Tests that need a real browser are marked `browser` and run in the Docker container; all other tests need neither a browser nor the network. + +Build the source documentation with Doxygen: + +```bash +doxygen Doxyfile +``` + +## ๐Ÿ“„ License + +GNU Affero General Public License v3.0, see [LICENSE][license]. + +## ๐Ÿ”— Links + +- [Repository][repository] +- [Documentation][documentation] +- [Issue tracker][issues] + +## ๐Ÿ“Ž Appendix: Project documents + +The documents a newcomer needs, in reading order. + +| Document | What it tells you | +| --- | --- | +| [AGENTS.md][agents] | The rules AI agents follow in this repository | +| [Business Case][BC-001] | Why the project exists, its scope and success criteria | +| [Stakeholder Analysis][SA-001] | Who is involved; the stakeholder IDs used for owners and reviewers | +| [Project Plan][PP-001] | The gateways and their schedule | +| [Milestones][milestones] | One document per gateway: Go/No-Go criteria and tasks | +| [Artifact registry][registry] | Where each artifact type lives, the next version, the PO language and domain | +| [Domain dictionary][DICT-001] | The PO terms and the IT terms | +| [Traceability matrix][TM-001] | How the artifacts link and when each was last reviewed | +| [Framework README][framework-readme] | The framework this project uses | +| [Framework changelog][framework-changelog] | What changed in each framework release | + +--- + +[gym]: https://appbrewery.github.io/gym/ +[license]: ./LICENSE +[repository]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift +[documentation]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/wiki +[issues]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/issues +[agents]: ./AGENTS.md +[BC-001]: ./docs/business-case.md +[SA-001]: ./docs/stakeholder-analysis.md +[PP-001]: ./docs/project-plan.md +[milestones]: ./docs/milestones/ +[registry]: ./docs/artifact-registry.md +[DICT-001]: ./docs/dictionary.md +[TM-001]: ./docs/sqa/traceability-matrix.md +[framework-readme]: ./framework/README.md +[framework-changelog]: ./framework/CHANGELOG.md diff --git a/docs/artifact-registry.md b/docs/artifact-registry.md index 9acb840..629dbaf 100644 --- a/docs/artifact-registry.md +++ b/docs/artifact-registry.md @@ -16,7 +16,7 @@ document of a type. `Primary File` may contain a glob (e.g. | PP | Project Plan | docs/project-plan.md | 002 | | MIL | Milestone / Gateway | docs/milestones/*.md | 009 | | ADR | Architecture Decision Record | docs/adr/adr-NNNN-*.md | 0004 | -| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 016 | +| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 017 | | TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 | | DICT | Domain Dictionary | docs/dictionary.md | 002 | diff --git a/docs/images/.gitkeep b/docs/images/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/sqa/reviews/rc-016-code-mil-002-scaffold.md b/docs/sqa/reviews/rc-016-code-mil-002-scaffold.md new file mode 100644 index 0000000..4cd8f6a --- /dev/null +++ b/docs/sqa/reviews/rc-016-code-mil-002-scaffold.md @@ -0,0 +1,64 @@ +# SQA Review Record + +## Metadata +| Key | Value | +| --- | --- | +| ID | RC-016 | +| CrossReference | [MIL-002], [QC-PY-001] | + +## Version History +| Date | Status | Author | Reviewer | Change | Commit | +| --- | --- | --- | --- | --- | --- | +| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S02 | Review record, full review of the source and tests of MIL-002 | [7fdc11a] | + +--- + +## Artifact Under Review + +- Instance reviewed: [MIL-002] +- Checklist used: [QC-PY-001] (`QC-PY-001`) +- Scope: full review of the source and tests of MIL-002 +- Language and domain: n/a +- Language reviewer: none +- Mode: autonomous, not independent (the reviewer is `AI as stand-in for S02`) + +## 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 and variables are `snake_case`, classes `PascalCase`, constants `UPPER_SNAKE`; `ruff check` with the N rules passes. | +| 2 | Names state purpose in the domain's language; no unexplained abbreviations, no single-letter names outside tiny scopes | Pass | Names state purpose (for example `retry`, `login`, `book_class`, `get_my_bookings`); no single-letter names outside comprehensions. | +| 3 | Code is produced by the project's formatter and passes its linter with no unexplained suppressions | Pass | `ruff format` leaves 0 files changed and `ruff check` reports 0 findings; there is no suppression. | +| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | Every function and method has annotations (ruff ANN rules pass), including `-> None`. | +| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | No bare `except`; specific exceptions are caught, and re-raised with `raise ... from` where the cause matters. | +| 6 | No mutable default arguments and no shadowed builtins | Pass | No mutable default arguments; no builtin is shadowed (ruff B and A-style checks pass). | +| 7 | Files, locks and connections are managed with context managers | Pass | Files and the browser are managed with `with`/`try-finally`; no unmanaged file handle exists. | +| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | Public modules, classes and functions have Doxygen docstrings (`@brief`, `@param`, `@return`). | +| 9 | Logging uses `logging`, not `print`; no secrets or personal data in log output | Pass | No print calls; the entry point logs through `logging`. | +| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | N-A | No Design Class Diagram exists; ADR-0001 records that design artifacts are skipped. Classes follow ADR-0002. | +| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | Unit tests cover the constants and the command line; the e2e smoke test is skipped outside the container and passed in it; no test depends on order or the network. | +| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment | N-A | No strict type checker is configured; annotations are complete and checked by ruff. | +| 13 | Dependencies are declared and pinned in the project's dependency file, none unused | Pass | `pyproject.toml` declares Selenium (runtime) and pytest, pytest-cov, ruff (dev) with lower bounds; none is unused. | +| 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 | Each rule, constant and message exists once (constants in `constants.py`, rules in `domain/`). | +| 15 | Imports point inward: business-rule modules import no framework, database, UI or delivery-mechanism code, and there are no import cycles | Pass | `domain` and `application` import no Selenium; only `infrastructure` imports it; no import cycle exists. | +| 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 | One reason to change per module; the adapter hides Selenium behind a port; fakes honour the port contract. | +| 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 | Layout follows `src/snack_and_lift/{domain,application,infrastructure}` and `tests/{unit,integration,e2e}`; there is no presentation layer for a command-line bot (ADR-0002). | +| 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 | All file and folder names are English lowercase with `_`, `-` and `.` only. | + +## Overall Verdict + +Go โ€” the code of MIL-002 meets every Mandatory criterion of QC-PY-001. No blocking finding. The review is not independent: the AI wrote and reviewed it as stand-in for S02, and S01 can challenge it in the end-of-run summary. + +Gate evidence for MIL-002: `pip install -e .[dev]` and `pytest` pass (6 passed, 1 skipped outside the container); `doxygen Doxyfile` reports no warning; the container image builds and the e2e smoke test passes in it (the home page has no title, so the test reads the login button instead of a page title). `check-readme.sh` reports one finding about the framework's own README (`qc-project-plan.md` is not named); the project README has none. The finding is upstream, outside this project, and is listed in the end-of-run summary. + +## Action Items + +| Action | Owner | Due | +| --- | --- | --- | +| None | S01 | - | + +--- + +[MIL-002]: ../../milestones/mil-002-scaffold.md +[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md +[7fdc11a]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/commit/7fdc11a0e7a8d2d1db63676c73c59b8fe999e0b4 diff --git a/docs/sqa/traceability-matrix.md b/docs/sqa/traceability-matrix.md index 92650b4..dae7958 100644 --- a/docs/sqa/traceability-matrix.md +++ b/docs/sqa/traceability-matrix.md @@ -36,6 +36,7 @@ Tracks backward and forward links between artifact instances so that the Busines | [MIL-006] | Milestone | en | it | [BC-001], [PP-001] | [MIL-007] | RC-013 | | [MIL-007] | Milestone | en | it | [BC-001], [PP-001] | [MIL-008] | RC-014 | | [MIL-008] | Milestone | en | it | [BC-001], [PP-001] | - | RC-015 | +| Source and tests of [MIL-002] | Python source code | - | - | [MIL-002], [ADR-0002], [ADR-0003] | - | RC-016 | ## Concern Coverage diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b362b2e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,52 @@ +[build-system] +requires = ["setuptools>=75"] +build-backend = "setuptools.build_meta" + +[project] +name = "snack-and-lift-bot" +version = "0.1.0" +description = "Selenium bot that books classes at the Snack and Lift practice gym (Udemy 100 Days of Code, day 49)." +readme = "README.md" +requires-python = ">=3.13" +license = { file = "LICENSE" } +dependencies = [ + "selenium>=4.25", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.3", + "pytest-cov>=6.0", + "ruff>=0.8", +] + +[project.scripts] +snack-and-lift = "snack_and_lift.cli:main" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-ra --strict-markers" +pythonpath = ["src"] +markers = [ + "browser: needs a real browser (run in the Docker container)", +] + +[tool.ruff] +line-length = 100 +target-version = "py313" +src = ["src", "tests"] +extend-exclude = ["framework", "chrome_profile"] + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "N", "UP", "B", "SIM", "ANN"] + +[tool.coverage.run] +source = ["snack_and_lift"] +omit = ["*/infrastructure/*", "*/cli.py", "*/__main__.py"] + +[tool.coverage.report] +fail_under = 80 +show_missing = true diff --git a/src/snack_and_lift/__init__.py b/src/snack_and_lift/__init__.py new file mode 100644 index 0000000..9c67967 --- /dev/null +++ b/src/snack_and_lift/__init__.py @@ -0,0 +1,3 @@ +"""@file __init__.py +@brief Snack and Lift booking bot: books gym classes with Selenium. +""" diff --git a/src/snack_and_lift/__main__.py b/src/snack_and_lift/__main__.py new file mode 100644 index 0000000..c258609 --- /dev/null +++ b/src/snack_and_lift/__main__.py @@ -0,0 +1,8 @@ +"""@file __main__.py +@brief Entry point for `python -m snack_and_lift`. +""" + +from snack_and_lift.cli import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/snack_and_lift/application/__init__.py b/src/snack_and_lift/application/__init__.py new file mode 100644 index 0000000..99a51e3 --- /dev/null +++ b/src/snack_and_lift/application/__init__.py @@ -0,0 +1,3 @@ +"""@file __init__.py +@brief Use of the rules through ports: login, booking, verification and retry. +""" diff --git a/src/snack_and_lift/cli.py b/src/snack_and_lift/cli.py new file mode 100644 index 0000000..5925ca9 --- /dev/null +++ b/src/snack_and_lift/cli.py @@ -0,0 +1,39 @@ +"""@file cli.py +@brief Command-line entry point (the composition root of the bot). +""" + +import argparse +import logging +from collections.abc import Sequence + +logger = logging.getLogger(__name__) + + +def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: + """@brief Read the command line. + + @param argv Arguments without the program name; None reads `sys.argv`. + @return The parsed options. + """ + parser = argparse.ArgumentParser( + prog="snack-and-lift", + description="Books the Tuesday and Thursday 6pm classes at the Snack and Lift gym.", + ) + parser.add_argument( + "--headless", + action="store_true", + help="run Chrome without a window (the Docker image does this)", + ) + return parser.parse_args(argv) + + +def main(argv: Sequence[str] | None = None) -> int: + """@brief Start the bot. + + @param argv Arguments without the program name; None reads `sys.argv`. + @return Process exit code. + """ + logging.basicConfig(level=logging.INFO, format="%(message)s") + parse_args(argv) + logger.info("snack-and-lift: the lecture steps are added by the next milestones") + return 0 diff --git a/src/snack_and_lift/constants.py b/src/snack_and_lift/constants.py new file mode 100644 index 0000000..8eee255 --- /dev/null +++ b/src/snack_and_lift/constants.py @@ -0,0 +1,45 @@ +"""@file constants.py +@brief Every constant of the bot: the site address, selectors, timings and messages. + +Selectors of the gym pages are added here by the milestone that first uses them. +""" + +from typing import Final + +## Address of the Snack and Lift practice site on GitHub Pages (appbrewery/gym). +GYM_URL: Final[str] = "https://appbrewery.github.io/gym/" + +## Folder, below the working directory, where Chrome keeps the profile of the bot. +PROFILE_DIR_NAME: Final[str] = "chrome_profile" + +## Longest time to wait for an element of the page, in seconds. +WAIT_TIMEOUT_SECONDS: Final[int] = 10 + +## Attempts of the `retry` wrapper before it gives up (lecture step 9). +RETRY_COUNT: Final[int] = 7 + +## Weekdays of the classes the bot books, as the site abbreviates them. +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). +NAV_LOGIN_BUTTON_ID: Final[str] = "login-button" + +## Folder where screenshots of a container run are written. +SCREENSHOT_DIR: Final[str] = "docs/images" + +## Name of the environment variable that holds the path of the browser binary. +CHROME_BIN_ENV: Final[str] = "CHROME_BIN" + +## Name of the environment variable that holds the path of the browser driver. +CHROMEDRIVER_PATH_ENV: Final[str] = "CHROMEDRIVER_PATH" + +## Window size of the browser, so screenshots look the same on every machine. +WINDOW_SIZE: Final[str] = "1280,1600" + +## Chromium arguments that a container needs to run without a display. +HEADLESS_BROWSER_ARGUMENTS: Final[tuple[str, ...]] = ( + "--headless=new", + "--no-sandbox", + "--disable-dev-shm-usage", + "--disable-gpu", +) diff --git a/src/snack_and_lift/domain/__init__.py b/src/snack_and_lift/domain/__init__.py new file mode 100644 index 0000000..f64a4a8 --- /dev/null +++ b/src/snack_and_lift/domain/__init__.py @@ -0,0 +1,3 @@ +"""@file __init__.py +@brief Booking rules and plain data; imports no Selenium. +""" diff --git a/src/snack_and_lift/infrastructure/__init__.py b/src/snack_and_lift/infrastructure/__init__.py new file mode 100644 index 0000000..ae14c97 --- /dev/null +++ b/src/snack_and_lift/infrastructure/__init__.py @@ -0,0 +1,3 @@ +"""@file __init__.py +@brief Selenium adapter: the only package that imports Selenium. +""" diff --git a/src/snack_and_lift/infrastructure/browser.py b/src/snack_and_lift/infrastructure/browser.py new file mode 100644 index 0000000..a6264a5 --- /dev/null +++ b/src/snack_and_lift/infrastructure/browser.py @@ -0,0 +1,33 @@ +"""@file browser.py +@brief Creates the Chrome driver, headless when asked. +""" + +import os + +from selenium import webdriver +from selenium.webdriver.chrome.options import Options +from selenium.webdriver.chrome.service import Service + +from snack_and_lift import constants + + +def create_driver(headless: 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. + @return A running Chrome driver. + """ + options = Options() + options.add_argument(f"--window-size={constants.WINDOW_SIZE}") + if headless: + for argument in constants.HEADLESS_BROWSER_ARGUMENTS: + options.add_argument(argument) + binary = os.environ.get(constants.CHROME_BIN_ENV) + if binary: + options.binary_location = binary + driver_path = os.environ.get(constants.CHROMEDRIVER_PATH_ENV) + service = Service(executable_path=driver_path) if driver_path else Service() + return webdriver.Chrome(service=service, options=options) diff --git a/tests/e2e/.gitkeep b/tests/e2e/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/e2e/test_browser_smoke.py b/tests/e2e/test_browser_smoke.py new file mode 100644 index 0000000..84e5f6e --- /dev/null +++ b/tests/e2e/test_browser_smoke.py @@ -0,0 +1,33 @@ +"""@file test_browser_smoke.py +@brief Real-browser smoke test; runs only in the Docker container (SNACK_AND_LIFT_E2E=1). +""" + +import os + +import pytest +from selenium.webdriver.common.by import By +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.infrastructure.browser import create_driver + +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", + ), +] + + +def test_gym_home_page_shows_the_login_button() -> None: + driver = create_driver(headless=True) + try: + driver.get(constants.GYM_URL) + button = WebDriverWait(driver, constants.WAIT_TIMEOUT_SECONDS).until( + ec.presence_of_element_located((By.ID, constants.NAV_LOGIN_BUTTON_ID)) + ) + assert button.text == "Login" + finally: + driver.quit() diff --git a/tests/integration/.gitkeep b/tests/integration/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py new file mode 100644 index 0000000..7a679e8 --- /dev/null +++ b/tests/unit/test_cli.py @@ -0,0 +1,17 @@ +"""@file test_cli.py +@brief The command line accepts its options and the entry point returns 0. +""" + +from snack_and_lift.cli import main, parse_args + + +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_main_returns_zero() -> None: + assert main([]) == 0 diff --git a/tests/unit/test_constants.py b/tests/unit/test_constants.py new file mode 100644 index 0000000..2fbcf5f --- /dev/null +++ b/tests/unit/test_constants.py @@ -0,0 +1,17 @@ +"""@file test_constants.py +@brief Smoke test: the assignment's values are the ones in constants.py. +""" + +from snack_and_lift import constants + + +def test_gym_address_is_https() -> None: + assert constants.GYM_URL.startswith("https://") + + +def test_retry_count_is_seven() -> None: + assert constants.RETRY_COUNT == 7 + + +def test_bot_books_tuesday_and_thursday() -> None: + assert constants.TARGET_WEEKDAYS == ("Tue", "Thu")