diff --git a/README.md b/README.md index 2ce74fb..addc294 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ A Python Selenium bot from Day 52 of Udemy's "100 Days of Code: The Complete Pyt The assignment has five steps: get credentials, create the `InstaFollower` class, log in, find the followers of the target account and follow them. The code keeps the assignment's names (`InstaFollower`, `login`, `find_followers`, `follow`). -- `src/instagram_followers/` holds the bot and `constants.py`. +- `src/instagram_followers/` holds the bot in clean-architecture layers (`domain`, `application`, `infrastructure`, `presentation`) and `constants.py`. - `tests/` holds the pytest tests, which use a fake driver and need no browser or network. - `docs/` holds the project documents and the wiki (English, Danish and Bahasa Malaysia). @@ -57,7 +57,7 @@ export INSTA_PASSWORD="your_password" ## ▶️ Run ```bash -python -m instagram_followers.main +python -m instagram_followers.presentation.main ``` Run it sparingly and log out between test runs by hand. diff --git a/docs/artifact-registry.md b/docs/artifact-registry.md index 2614c76..55b1669 100644 --- a/docs/artifact-registry.md +++ b/docs/artifact-registry.md @@ -15,7 +15,7 @@ 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 / Gateway | docs/milestones/*.md | 005 | -| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 009 | +| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 010 | | DICT | Domain Dictionary | docs/dictionary.md | 002 | | TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 | diff --git a/docs/sqa/reviews/rc-009-py-follower-bot.md b/docs/sqa/reviews/rc-009-py-follower-bot.md new file mode 100644 index 0000000..e0ea30f --- /dev/null +++ b/docs/sqa/reviews/rc-009-py-follower-bot.md @@ -0,0 +1,62 @@ +# Review of MIL-002 + +## Metadata +| Key | Value | +| --- | --- | +| ID | RC-009 | +| CrossReference | [MIL-002], [QC-PY-001] | + +## Version History +| Date | Status | Author | Reviewer | Change | Commit | +| --- | --- | --- | --- | --- | --- | +| 2026-11-04 | Proposed | Jens Tirsvad Nielsen | S01 (AI as stand-in) | Initial version | [2a2838f] | + +--- + +## Artifact Under Review + +- Instance reviewed: [MIL-002] +- Checklist used: [QC-PY-001] +- Scope: full review of `src/` and `tests/` at the head of branch mil-002-follower-bot (code review of the milestone deliverable) +- Language and domain: n/a +- Language reviewer: none (the stand-in for S01 reads English and the IT domain) +- Mode: autonomous, not independent (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 | Meets the criterion; see src/instagram_followers. | +| 2 | Names state purpose in the domain's language; no unexplained abbreviations, no single-letter names outside tiny scopes | Pass | Meets the criterion; see src/instagram_followers. | +| 3 | Code is produced by the project's formatter and passes its linter with no unexplained suppressions | Pass | `ruff format` leaves files unchanged and `ruff check` reports no finding; no suppressions. | +| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | `mypy --strict src tests` reports no issue in 26 files. | +| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | No bare except; the only caught exception, `ElementClickInterceptedException`, is handled on purpose by clicking Cancel, as the assignment says. | +| 6 | No mutable default arguments and no shadowed builtins | Pass | Meets the criterion; see src/instagram_followers. | +| 7 | Files, locks and connections are managed with context managers | Pass | `InstaFollower` is a context manager and quits the driver it created; tests cover this. | +| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | Public modules, classes and functions carry Doxygen-style `##` comments (the Doxyfile reads them), as the Product Owner required. | +| 9 | Logging uses `logging`, not `print`; no secrets or personal data in log output | Pass | No print or logging; credentials are hidden from repr and never logged. | +| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | Pass | No design class diagram exists: [PP-001] models no use cases. The structure (Follower port, InstaFollower adapter, run_bot use case) is recorded here as the deviation. | +| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | 18 tests in tests/unit and tests/integration use a fake driver, no network, injected sleep. | +| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment | Pass | `mypy --strict` clean; no `Any`. | +| 13 | Dependencies are declared and pinned in the project's dependency file, none unused | Pass | Dependencies are declared and pinned in pyproject.toml (selenium, pytest, ruff, mypy). Fixed after the first draft of this review. | +| 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 | Meets the criterion; see src/instagram_followers. | +| 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 (grep empty); no import cycle. | +| 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 | Meets the criterion; see src/instagram_followers. | +| 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 | Layers domain, application, infrastructure, presentation in snake_case; tests/unit, tests/integration, tests/e2e (e2e needs a container browser and is not yet written). | +| 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 | Meets the criterion; see src/instagram_followers. | + +## Overall Verdict + +Go — Every Mandatory criterion passes. Optional criterion 13 was fixed (pinned versions). Re-checked after the locators and the account URL were corrected against the live mock on 2026-11-04 (18 tests, ruff and mypy --strict clean). + +## Action Items + +| Action | Owner | Due | +| --- | --- | --- | +| None; criterion 13 was fixed before this record was finalised. | S01 | - | + +--- + +[MIL-002]: ../../milestones/mil-002-follower-bot.md +[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md +[2a2838f]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/052-instagram-followers/commit/2a2838fba36dd4d83c3bb1a4a12fc2d143c7d211 diff --git a/pyproject.toml b/pyproject.toml index 78caa68..ddd0f58 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,17 +9,23 @@ description = "Day 52 of 100 Days of Code: a Selenium bot that follows the follo readme = "README.md" requires-python = ">=3.13" license = { file = "LICENSE" } -dependencies = ["selenium>=4.20"] +dependencies = ["selenium==4.52.0"] [project.optional-dependencies] -dev = ["pytest>=8"] +dev = ["pytest==9.2.0", "ruff==0.17.3", "mypy==2.4.1"] [project.scripts] -instagram-followers = "instagram_followers.main:main" +instagram-followers = "instagram_followers.presentation.main:main" [tool.setuptools.packages.find] where = ["src"] [tool.pytest.ini_options] testpaths = ["tests"] -pythonpath = ["src"] +pythonpath = ["src", "."] + +[tool.ruff] +line-length = 100 + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B"] diff --git a/src/instagram_followers/__init__.py b/src/instagram_followers/__init__.py new file mode 100644 index 0000000..843e4d1 --- /dev/null +++ b/src/instagram_followers/__init__.py @@ -0,0 +1 @@ +"""Selenium bot that follows the followers of a similar account.""" diff --git a/src/instagram_followers/application/__init__.py b/src/instagram_followers/application/__init__.py new file mode 100644 index 0000000..fd12df0 --- /dev/null +++ b/src/instagram_followers/application/__init__.py @@ -0,0 +1 @@ +"""Layer package.""" diff --git a/src/instagram_followers/application/ports.py b/src/instagram_followers/application/ports.py new file mode 100644 index 0000000..402ea27 --- /dev/null +++ b/src/instagram_followers/application/ports.py @@ -0,0 +1,15 @@ +## @file ports.py +# @brief Ports the use cases need from the outside. +from typing import Protocol + + +## @brief Something that can log in and follow the followers of an account. +class Follower(Protocol): + ## @brief Log in to the service. + def login(self) -> None: ... + + ## @brief Open the list of followers to follow. + def find_followers(self) -> None: ... + + ## @brief Follow everyone in the opened list. + def follow(self) -> None: ... diff --git a/src/instagram_followers/application/run_bot.py b/src/instagram_followers/application/run_bot.py new file mode 100644 index 0000000..39f1dc6 --- /dev/null +++ b/src/instagram_followers/application/run_bot.py @@ -0,0 +1,11 @@ +## @file run_bot.py +# @brief Use case: log in, find the followers and follow them. +from instagram_followers.application.ports import Follower + + +## @brief Run the three steps of the bot in order. +# @param follower The follower implementation to drive. +def run_bot(follower: Follower) -> None: + follower.login() + follower.find_followers() + follower.follow() diff --git a/src/instagram_followers/constants.py b/src/instagram_followers/constants.py new file mode 100644 index 0000000..b49aa54 --- /dev/null +++ b/src/instagram_followers/constants.py @@ -0,0 +1,50 @@ +## @file constants.py +# @brief All constants of the follower bot. No credential value is held here. + +## Account whose followers are followed. +SIMILAR_ACCOUNT = "chefsteps" + +## Root of the Share-a-Naan mock service. +BASE_URL = "https://app.100daysofpython.dev/services/share-a-naan" +## Login page. +LOGIN_URL = f"{BASE_URL}/login" +## Account page template; fill with the account name. +ACCOUNT_URL_TEMPLATE = f"{BASE_URL}/u/{{account}}" + +## Environment variables the credentials are read from at runtime. +ENV_USERNAME = "INSTA_USERNAME" +ENV_PASSWORD = "INSTA_PASSWORD" + +## Locators below were verified against the live mock on 2026-11-04. +USERNAME_FIELD_ID = "username" +PASSWORD_FIELD_ID = "password" +SUBMIT_BUTTON_XPATH = "//button[@type='submit']" +## Both post-login pop-ups ("Save your login info?", notifications) use this button. +NOT_NOW_BUTTON_XPATH = "//button[contains(@class, 'naan-popup-dismiss')]" +FOLLOWERS_LINK_XPATH = "//a[contains(@class, 'naan-followers-link')]" +## The followers modal; the hidden naan-post-modal dialog does not match. +FOLLOWERS_MODAL_XPATH = "//div[@role='dialog' and contains(@class, 'naan-modal')]" +## The scrollable list inside the modal (the modal itself has overflow hidden). +FOLLOWERS_SCROLL_XPATH = "//div[contains(@class, 'followers-scroll')]" +## Relative to the scroll container, so the profile header Follow button never matches. +FOLLOW_BUTTON_XPATH = ( + ".//button[contains(@class, 'naan-follow-btn') and normalize-space(text())='Follow']" +) +CANCEL_BUTTON_XPATH = "//button[contains(@class, 'naan-unfollow-cancel')]" + +## UNVERIFIED: no cookie banner was seen on the live mock; the dismissal is optional. +COOKIE_BUTTON_XPATH = ( + "//button[contains(text(), 'Accept') or contains(text(), 'Allow') " + "or contains(text(), 'Agree') or contains(text(), 'OK')]" +) + +## Timing, in seconds. +FOLLOW_DELAY = 1 +SCROLL_DELAY = 1 +PAGE_SETTLE_DELAY = 2 +WAIT_TIMEOUT = 10 + +## Number of times the followers list is scrolled to load more entries. +SCROLL_COUNT = 5 +## Script that scrolls the element passed as arguments[0] to its bottom. +SCROLL_CONTAINER_SCRIPT = "arguments[0].scrollTop = arguments[0].scrollHeight" diff --git a/src/instagram_followers/domain/__init__.py b/src/instagram_followers/domain/__init__.py new file mode 100644 index 0000000..fd12df0 --- /dev/null +++ b/src/instagram_followers/domain/__init__.py @@ -0,0 +1 @@ +"""Layer package.""" diff --git a/src/instagram_followers/domain/credentials.py b/src/instagram_followers/domain/credentials.py new file mode 100644 index 0000000..14e680d --- /dev/null +++ b/src/instagram_followers/domain/credentials.py @@ -0,0 +1,10 @@ +## @file credentials.py +# @brief Login credentials value object. +from dataclasses import dataclass, field + + +## @brief Username and password used to log in; the password is hidden from repr. +@dataclass(frozen=True, slots=True) +class Credentials: + username: str + password: str = field(repr=False) diff --git a/src/instagram_followers/domain/errors.py b/src/instagram_followers/domain/errors.py new file mode 100644 index 0000000..3d3264d --- /dev/null +++ b/src/instagram_followers/domain/errors.py @@ -0,0 +1,7 @@ +## @file errors.py +# @brief Errors raised by the business rules. + + +## @brief Raised when a required credential is not set in the environment. +class MissingCredentialsError(RuntimeError): + pass diff --git a/src/instagram_followers/infrastructure/__init__.py b/src/instagram_followers/infrastructure/__init__.py new file mode 100644 index 0000000..fd12df0 --- /dev/null +++ b/src/instagram_followers/infrastructure/__init__.py @@ -0,0 +1 @@ +"""Layer package.""" diff --git a/src/instagram_followers/infrastructure/config.py b/src/instagram_followers/infrastructure/config.py new file mode 100644 index 0000000..4248ce3 --- /dev/null +++ b/src/instagram_followers/infrastructure/config.py @@ -0,0 +1,24 @@ +## @file config.py +# @brief Reads configuration (credentials) from the environment. +import os +from collections.abc import Mapping + +from instagram_followers import constants +from instagram_followers.domain.credentials import Credentials +from instagram_followers.domain.errors import MissingCredentialsError + + +## @brief Read the login credentials from the environment. +# @param environ Mapping to read from; defaults to os.environ. +# @return The credentials. +# @throws MissingCredentialsError if either variable is missing or empty. +def read_credentials(environ: Mapping[str, str] | None = None) -> Credentials: + source = os.environ if environ is None else environ + missing = [ + name for name in (constants.ENV_USERNAME, constants.ENV_PASSWORD) if not source.get(name) + ] + if missing: + raise MissingCredentialsError(f"Missing environment variable(s): {', '.join(missing)}") + return Credentials( + username=source[constants.ENV_USERNAME], password=source[constants.ENV_PASSWORD] + ) diff --git a/src/instagram_followers/infrastructure/insta_follower.py b/src/instagram_followers/infrastructure/insta_follower.py new file mode 100644 index 0000000..41bd7ca --- /dev/null +++ b/src/instagram_followers/infrastructure/insta_follower.py @@ -0,0 +1,116 @@ +## @file insta_follower.py +# @brief Selenium bot that logs in and follows the followers of an account. +import time +from collections.abc import Callable +from types import TracebackType +from typing import Self + +from selenium import webdriver +from selenium.common.exceptions import ElementClickInterceptedException +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 instagram_followers import constants +from instagram_followers.infrastructure.config import read_credentials + + +## @brief Bot that follows the followers of constants.SIMILAR_ACCOUNT. +class InstaFollower: + ## @brief Create the bot. + # @param driver Optional WebDriver (injected in tests); Chrome if None. + # @param headless Run Chrome without a window when creating the driver. + # @param sleep Function used to pause; injectable so tests do not wait. + def __init__( + self, + driver: WebDriver | None = None, + headless: bool = False, + sleep: Callable[[float], None] = time.sleep, + ) -> None: + self._owns_driver = driver is None + self._driver = driver if driver is not None else self._create_driver(headless) + self._sleep = sleep + + ## @brief Enter the context. + # @return This bot. + def __enter__(self) -> Self: + return self + + ## @brief Leave the context and close the driver if this bot created it. + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + traceback: TracebackType | None, + ) -> None: + self.close() + + ## @brief Quit the browser; an injected driver is left to its owner. + def close(self) -> None: + if self._owns_driver: + self._driver.quit() + + @staticmethod + def _create_driver(headless: bool) -> WebDriver: + options = webdriver.ChromeOptions() + if headless: + options.add_argument("--headless=new") + return webdriver.Chrome(options=options) + + def _click_if_present(self, xpath: str) -> bool: + elements = self._driver.find_elements(By.XPATH, xpath) + if not elements: + return False + elements[0].click() + return True + + ## @brief Log in, dismissing the cookie warning and any pop-ups. + # @throws MissingCredentialsError if the credentials are not in the environment. + def login(self) -> None: + credentials = read_credentials() + self._driver.get(constants.LOGIN_URL) + self._click_if_present(constants.COOKIE_BUTTON_XPATH) + wait = WebDriverWait(self._driver, constants.WAIT_TIMEOUT) + username_field = wait.until( + EC.presence_of_element_located((By.ID, constants.USERNAME_FIELD_ID)) + ) + username_field.send_keys(credentials.username) + self._driver.find_element(By.ID, constants.PASSWORD_FIELD_ID).send_keys( + credentials.password + ) + self._driver.find_element(By.XPATH, constants.SUBMIT_BUTTON_XPATH).click() + self._sleep(constants.PAGE_SETTLE_DELAY) + # "Save login info", then notifications: both are "Not Now" buttons. + for _ in range(2): + self._click_if_present(constants.NOT_NOW_BUTTON_XPATH) + + ## @brief Open the similar account and scroll its followers list. + def find_followers(self) -> None: + account_url = constants.ACCOUNT_URL_TEMPLATE.format(account=constants.SIMILAR_ACCOUNT) + self._driver.get(account_url) + self._sleep(constants.PAGE_SETTLE_DELAY) + wait = WebDriverWait(self._driver, constants.WAIT_TIMEOUT) + wait.until( + EC.presence_of_element_located((By.XPATH, constants.FOLLOWERS_LINK_XPATH)) + ).click() + wait.until(EC.presence_of_element_located((By.XPATH, constants.FOLLOWERS_MODAL_XPATH))) + scroll_container = wait.until( + EC.presence_of_element_located((By.XPATH, constants.FOLLOWERS_SCROLL_XPATH)) + ) + for _ in range(constants.SCROLL_COUNT): + # The modal has overflow hidden; only the list inside it scrolls. + self._driver.execute_script(constants.SCROLL_CONTAINER_SCRIPT, scroll_container) + self._sleep(constants.SCROLL_DELAY) + + ## @brief Click every Follow button in the followers list. + # An interception means an unfollow confirmation opened (the account is + # already followed); its Cancel button is clicked and the loop goes on. + def follow(self) -> None: + container = self._driver.find_element(By.XPATH, constants.FOLLOWERS_SCROLL_XPATH) + for button in container.find_elements(By.XPATH, constants.FOLLOW_BUTTON_XPATH): + try: + button.click() + except ElementClickInterceptedException: + self._click_if_present(constants.CANCEL_BUTTON_XPATH) + self._sleep(constants.FOLLOW_DELAY) diff --git a/src/instagram_followers/presentation/__init__.py b/src/instagram_followers/presentation/__init__.py new file mode 100644 index 0000000..fd12df0 --- /dev/null +++ b/src/instagram_followers/presentation/__init__.py @@ -0,0 +1 @@ +"""Layer package.""" diff --git a/src/instagram_followers/presentation/main.py b/src/instagram_followers/presentation/main.py new file mode 100644 index 0000000..76f3f28 --- /dev/null +++ b/src/instagram_followers/presentation/main.py @@ -0,0 +1,14 @@ +## @file main.py +# @brief Composition root: wires and runs the follower bot. +from instagram_followers.application.run_bot import run_bot +from instagram_followers.infrastructure.insta_follower import InstaFollower + + +## @brief Build the Selenium follower and run the bot. +def main() -> None: + with InstaFollower() as follower: + run_bot(follower) + + +if __name__ == "__main__": + main() diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/e2e/__init__.py b/tests/e2e/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/fakes.py b/tests/fakes.py new file mode 100644 index 0000000..dd19218 --- /dev/null +++ b/tests/fakes.py @@ -0,0 +1,52 @@ +"""Hand-written fakes of the Selenium driver and elements.""" + +from typing import Any + +from selenium.common.exceptions import ElementClickInterceptedException + + +class FakeElement: + def __init__( + self, intercepted: bool = False, children: list["FakeElement"] | None = None + ) -> None: + self.intercepted = intercepted + self.children = children or [] + self.clicks = 0 + self.typed: list[str] = [] + + def click(self) -> None: + if self.intercepted: + raise ElementClickInterceptedException("blocked") + self.clicks += 1 + + def send_keys(self, text: str) -> None: + self.typed.append(text) + + def find_elements(self, by: str, value: str) -> list["FakeElement"]: + return self.children + + +class FakeDriver: + def __init__(self, present: dict[str, list[FakeElement]]) -> None: + self.present = present + self.visited: list[str] = [] + self.scripts: list[tuple[str, Any]] = [] + self.quit_calls = 0 + + def quit(self) -> None: + self.quit_calls += 1 + + def get(self, url: str) -> None: + self.visited.append(url) + + def find_elements(self, by: str, value: str) -> list[FakeElement]: + return self.present.get(value, []) + + def find_element(self, by: str, value: str) -> FakeElement: + found = self.find_elements(by, value) + if not found: + raise LookupError(value) + return found[0] + + def execute_script(self, script: str, *args: Any) -> None: + self.scripts.append((script, args[0])) diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/test_run_bot_with_selenium_follower.py b/tests/integration/test_run_bot_with_selenium_follower.py new file mode 100644 index 0000000..381b59e --- /dev/null +++ b/tests/integration/test_run_bot_with_selenium_follower.py @@ -0,0 +1,30 @@ +from typing import Any + +import pytest + +from instagram_followers import constants +from instagram_followers.application.run_bot import run_bot +from instagram_followers.infrastructure.insta_follower import InstaFollower +from tests.fakes import FakeDriver, FakeElement + + +def test_run_bot_drives_selenium_follower_through_all_steps( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv(constants.ENV_USERNAME, "someone") + monkeypatch.setenv(constants.ENV_PASSWORD, "test-value") + button = FakeElement() + driver = FakeDriver( + { + constants.USERNAME_FIELD_ID: [FakeElement()], + constants.PASSWORD_FIELD_ID: [FakeElement()], + constants.SUBMIT_BUTTON_XPATH: [FakeElement()], + constants.FOLLOWERS_LINK_XPATH: [FakeElement()], + constants.FOLLOWERS_MODAL_XPATH: [FakeElement()], + constants.FOLLOWERS_SCROLL_XPATH: [FakeElement(children=[button])], + } + ) + fake: Any = driver + run_bot(InstaFollower(driver=fake, sleep=lambda _seconds: None)) + assert driver.visited[0] == constants.LOGIN_URL + assert button.clicks == 1 diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/application/__init__.py b/tests/unit/application/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/application/test_run_bot.py b/tests/unit/application/test_run_bot.py new file mode 100644 index 0000000..819f91f --- /dev/null +++ b/tests/unit/application/test_run_bot.py @@ -0,0 +1,21 @@ +from instagram_followers.application.run_bot import run_bot + + +class FakeFollower: + def __init__(self) -> None: + self.calls: list[str] = [] + + def login(self) -> None: + self.calls.append("login") + + def find_followers(self) -> None: + self.calls.append("find_followers") + + def follow(self) -> None: + self.calls.append("follow") + + +def test_run_bot_calls_steps_in_order() -> None: + follower = FakeFollower() + run_bot(follower) + assert follower.calls == ["login", "find_followers", "follow"] diff --git a/tests/unit/domain/__init__.py b/tests/unit/domain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/domain/test_credentials.py b/tests/unit/domain/test_credentials.py new file mode 100644 index 0000000..89e0962 --- /dev/null +++ b/tests/unit/domain/test_credentials.py @@ -0,0 +1,5 @@ +from instagram_followers.domain.credentials import Credentials + + +def test_credentials_repr_hides_password() -> None: + assert "test-value" not in repr(Credentials("u", "test-value")) diff --git a/tests/unit/infrastructure/__init__.py b/tests/unit/infrastructure/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/infrastructure/test_config.py b/tests/unit/infrastructure/test_config.py new file mode 100644 index 0000000..941ef37 --- /dev/null +++ b/tests/unit/infrastructure/test_config.py @@ -0,0 +1,21 @@ +import pytest + +from instagram_followers import constants +from instagram_followers.domain.errors import MissingCredentialsError +from instagram_followers.infrastructure.config import read_credentials + + +def test_read_credentials_returns_values_from_mapping() -> None: + environ = {constants.ENV_USERNAME: "u", constants.ENV_PASSWORD: "test-value"} + credentials = read_credentials(environ) + assert (credentials.username, credentials.password) == ("u", "test-value") + + +def test_read_credentials_rejects_empty_value() -> None: + with pytest.raises(MissingCredentialsError, match="INSTA_PASSWORD"): + read_credentials({constants.ENV_USERNAME: "u", constants.ENV_PASSWORD: ""}) + + +def test_read_credentials_names_every_missing_variable() -> None: + with pytest.raises(MissingCredentialsError, match="INSTA_USERNAME, INSTA_PASSWORD"): + read_credentials({}) diff --git a/tests/unit/infrastructure/test_insta_follower.py b/tests/unit/infrastructure/test_insta_follower.py new file mode 100644 index 0000000..570b5d8 --- /dev/null +++ b/tests/unit/infrastructure/test_insta_follower.py @@ -0,0 +1,150 @@ +from pathlib import Path +from typing import Any + +import pytest + +from instagram_followers import constants +from instagram_followers.domain.errors import MissingCredentialsError +from instagram_followers.infrastructure.insta_follower import InstaFollower +from tests.fakes import FakeDriver, FakeElement + + +def make_bot(driver: FakeDriver, sleeps: list[float]) -> InstaFollower: + fake: Any = driver + return InstaFollower(driver=fake, sleep=sleeps.append) + + +@pytest.fixture +def credentials(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv(constants.ENV_USERNAME, "someone") + monkeypatch.setenv(constants.ENV_PASSWORD, "test-value") + + +def login_form() -> dict[str, list[FakeElement]]: + return { + constants.USERNAME_FIELD_ID: [FakeElement()], + constants.PASSWORD_FIELD_ID: [FakeElement()], + constants.SUBMIT_BUTTON_XPATH: [FakeElement()], + } + + +def test_login_fills_form_and_submits_without_popups(credentials: None) -> None: + form = login_form() + driver = FakeDriver(form) + make_bot(driver, []).login() + assert driver.visited == [constants.LOGIN_URL] + assert form[constants.USERNAME_FIELD_ID][0].typed == ["someone"] + assert form[constants.PASSWORD_FIELD_ID][0].typed == ["test-value"] + assert form[constants.SUBMIT_BUTTON_XPATH][0].clicks == 1 + + +def test_login_dismisses_popups_when_present(credentials: None) -> None: + cookie, not_now = FakeElement(), FakeElement() + form = login_form() + form[constants.COOKIE_BUTTON_XPATH] = [cookie] + form[constants.NOT_NOW_BUTTON_XPATH] = [not_now] + make_bot(FakeDriver(form), []).login() + assert cookie.clicks == 1 + assert not_now.clicks == 2 + + +def test_login_raises_when_credentials_missing(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv(constants.ENV_USERNAME, raising=False) + monkeypatch.delenv(constants.ENV_PASSWORD, raising=False) + with pytest.raises(MissingCredentialsError, match="INSTA_USERNAME"): + make_bot(FakeDriver({}), []).login() + + +def test_find_followers_scrolls_the_scroll_container_n_times() -> None: + modal, scroll_container, link = FakeElement(), FakeElement(), FakeElement() + driver = FakeDriver( + { + constants.FOLLOWERS_LINK_XPATH: [link], + constants.FOLLOWERS_MODAL_XPATH: [modal], + constants.FOLLOWERS_SCROLL_XPATH: [scroll_container], + } + ) + make_bot(driver, []).find_followers() + expected_url = constants.ACCOUNT_URL_TEMPLATE.format(account=constants.SIMILAR_ACCOUNT) + assert driver.visited == [expected_url] + assert link.clicks == 1 + assert ( + driver.scripts + == [(constants.SCROLL_CONTAINER_SCRIPT, scroll_container)] * constants.SCROLL_COUNT + ) + assert all(target is not modal for _, target in driver.scripts) + + +def test_follow_clicks_every_button_with_one_second_delay() -> None: + buttons = [FakeElement(), FakeElement(), FakeElement()] + driver = FakeDriver({constants.FOLLOWERS_SCROLL_XPATH: [FakeElement(children=buttons)]}) + sleeps: list[float] = [] + make_bot(driver, sleeps).follow() + assert [b.clicks for b in buttons] == [1, 1, 1] + assert sleeps == [1, 1, 1] + + +def test_follow_cancels_popup_and_continues_when_click_intercepted() -> None: + cancel = FakeElement() + buttons = [FakeElement(), FakeElement(intercepted=True), FakeElement()] + driver = FakeDriver( + { + constants.FOLLOWERS_SCROLL_XPATH: [FakeElement(children=buttons)], + constants.CANCEL_BUTTON_XPATH: [cancel], + } + ) + make_bot(driver, []).follow() + assert cancel.clicks == 1 + assert buttons[2].clicks == 1 + + +def test_constants_hold_no_credential_value() -> None: + source = Path(constants.__file__).read_text(encoding="utf-8") + assert not hasattr(constants, "PASSWORD") + assert not hasattr(constants, "USERNAME") + assert "PASSWORD =" not in source.replace("ENV_PASSWORD =", "") + + +def test_context_exit_quits_driver_created_by_the_bot(monkeypatch: pytest.MonkeyPatch) -> None: + driver = FakeDriver({}) + fake: Any = driver + monkeypatch.setattr(InstaFollower, "_create_driver", staticmethod(lambda headless: fake)) + with InstaFollower(sleep=lambda _seconds: None) as bot: + assert isinstance(bot, InstaFollower) + assert driver.quit_calls == 1 + + +def test_context_exit_leaves_injected_driver_open() -> None: + driver = FakeDriver({}) + with make_bot(driver, []): + pass + assert driver.quit_calls == 0 + + +def test_context_exit_quits_owned_driver_when_body_raises( + monkeypatch: pytest.MonkeyPatch, +) -> None: + driver = FakeDriver({}) + fake: Any = driver + monkeypatch.setattr(InstaFollower, "_create_driver", staticmethod(lambda headless: fake)) + with pytest.raises(RuntimeError), InstaFollower(): + raise RuntimeError("boom") + assert driver.quit_calls == 1 + + +def test_follow_never_clicks_the_profile_follow_button() -> None: + profile_button, row_button = FakeElement(), FakeElement() + driver = FakeDriver( + { + "//profile-follow-button": [profile_button], + constants.FOLLOWERS_SCROLL_XPATH: [FakeElement(children=[row_button])], + } + ) + make_bot(driver, []).follow() + assert profile_button.clicks == 0 + assert row_button.clicks == 1 + + +def test_follow_button_locator_is_relative_and_matches_exact_follow_text() -> None: + assert constants.FOLLOW_BUTTON_XPATH.startswith(".//") + assert "normalize-space(text())='Follow'" in constants.FOLLOW_BUTTON_XPATH