Author SHA1 Message Date
Tirsvad f4d8b41cdf Add lecture-step screenshots and README steps (MIL-003)
TirSystem/git-workflows: OpenGrep / opengrep (push) Skipped
TirSystem/git-workflows: Sync GitHub mirror metadata / sync-metadata (push) Skipped
TirSystem/git-workflows: Publish accepted milestones / publish (push) Skipped
TirSystem/git-workflows: Validate / validate (push) Skipped
TirSystem/git-workflows: Validate / validate (pull_request) Successful in 36s
TirSystem/git-workflows: Publish wiki / publish-wiki (push) Skipped
TirSystem/git-workflows: OpenGrep / opengrep (pull_request) Successful in 5m13s
Task: MIL-003#1
2026-11-04 12:34:35 +08:00
Tirsvad e9083665f1 Correct URL and locators against the live mock (MIL-002)
TirSystem/git-workflows: OpenGrep / opengrep (push) Skipped
TirSystem/git-workflows: Sync GitHub mirror metadata / sync-metadata (push) Skipped
TirSystem/git-workflows: Validate / validate (push) Skipped
TirSystem/git-workflows: Publish wiki / publish-wiki (push) Skipped
TirSystem/git-workflows: Publish accepted milestones / publish (push) Skipped
TirSystem/git-workflows: Validate / validate (pull_request) Successful in 33s
TirSystem/git-workflows: OpenGrep / opengrep (pull_request) Successful in 5m3s
Task: MIL-002#3
2026-11-04 12:33:54 +08:00
Tirsvad 1803a2a23f Resolve pending commit links (MIL-002)
TirSystem/git-workflows: OpenGrep / opengrep (push) Skipped
TirSystem/git-workflows: Publish wiki / publish-wiki (push) Skipped
TirSystem/git-workflows: Sync GitHub mirror metadata / sync-metadata (push) Skipped
TirSystem/git-workflows: Publish accepted milestones / publish (push) Skipped
TirSystem/git-workflows: Validate / validate (push) Skipped
TirSystem/git-workflows: Validate / validate (pull_request) Successful in 32s
TirSystem/git-workflows: OpenGrep / opengrep (pull_request) Successful in 3m16s
2026-11-04 12:13:15 +08:00
Tirsvad 2a2838fba3 Add InstaFollower bot, tests and Python review (MIL-002)
Task: MIL-002#1
2026-11-04 12:13:13 +08:00
40 changed files with 646 additions and 8 deletions
+42 -3
View File
@@ -17,12 +17,41 @@ 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).
The bot targets the mock service Share-a-Naan. Running bots against the real Instagram can get an account banned and is not supported.
### Lecture steps
Each step of the assignment, with what the screen looks like on the Share-a-Naan mock. Screenshots were taken on 2026-11-04.
**Step 1 - Get your credentials.** The mock's profile page lists your login (your email is the username) and the target accounts to pick `SIMILAR_ACCOUNT` from. The password is read from the environment and never stored in the repository.
![Target accounts on the Share-a-Naan profile page][img-step1]
**Step 2 - Create a class.** `InstaFollower` creates the Selenium driver in `__init__` and has the three methods `login()`, `find_followers()` and `follow()`.
![Outline of the InstaFollower class][img-step2]
**Step 3 - Log in.** The login page, then two pop-ups that are dismissed with the "Not Now" button, found by its text with `contains()`.
![Share-a-Naan login page][img-step3-login]
![Save your login info pop-up][img-step3-save]
![Turn on notifications pop-up][img-step3-notifications]
**Step 4 - Find the followers.** Open `/u/<account>`, click the followers link and scroll the list inside the modal (the `followers-scroll` element), not the page behind it.
![Profile of the target account][img-step4-profile]
![Followers modal][img-step4-modal]
![Followers modal scrolled to the end][img-step4-scrolled]
**Step 5 - Follow all the followers.** Click every "Follow" button with a 1 second pause. If a click is intercepted by the "Unfollow?" pop-up (an account that is already followed), click "Cancel" and carry on.
![Unfollow pop-up with a Cancel button][img-step5-unfollow]
![Followers after the follow loop][img-step5-followed]
## 📋 Requirements
- Python 3.13 or newer.
@@ -57,10 +86,10 @@ 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.
Run it sparingly and log out between test runs by hand. The mock has a "Reset Share-a-Naan" button on its profile page that clears your follows for a clean re-run.
## 🧪 Tests
@@ -99,6 +128,16 @@ The documents a newcomer needs, in reading order.
---
[img-step1]: ./docs/images/step1-target-accounts.jpg
[img-step2]: ./docs/images/step2-class-outline.png
[img-step3-login]: ./docs/images/step3-login-page.jpg
[img-step3-save]: ./docs/images/step3-save-info-popup.jpg
[img-step3-notifications]: ./docs/images/step3-notifications-popup.jpg
[img-step4-profile]: ./docs/images/step4-target-profile.jpg
[img-step4-modal]: ./docs/images/step4-followers-modal.jpg
[img-step4-scrolled]: ./docs/images/step4-followers-scrolled.jpg
[img-step5-unfollow]: ./docs/images/step5-unfollow-popup.jpg
[img-step5-followed]: ./docs/images/step5-followed.jpg
[license]: ./LICENSE
[repository]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/052-instagram-followers
[documentation]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/052-instagram-followers/wiki
+1 -1
View File
@@ -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 |
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

@@ -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
View File
@@ -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"]
+1
View File
@@ -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()
+50
View File
@@ -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)
+7
View File
@@ -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()
View File
View File
+52
View File
@@ -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]))
View File
@@ -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
View File
View File
+21
View File
@@ -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"]
View File
+5
View File
@@ -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"))
+21
View File
@@ -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