Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9083665f1 | ||
|
|
1803a2a23f | ||
|
|
2a2838fba3 |
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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
|
||||
+10
-4
@@ -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"]
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Selenium bot that follows the followers of a similar account."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Layer package."""
|
||||
@@ -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: ...
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -0,0 +1 @@
|
||||
"""Layer package."""
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
"""Layer package."""
|
||||
@@ -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]
|
||||
)
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
"""Layer package."""
|
||||
@@ -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()
|
||||
@@ -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]))
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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"))
|
||||
@@ -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({})
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user