MIL-002 Follower bot #18

Open
Tirsvad wants to merge 3 commits from mil-002-follower-bot into mil-001-project-setup
5 changed files with 63 additions and 29 deletions
Showing only changes of commit e9083665f1 - Show all commits
+2 -2
View File
@@ -36,7 +36,7 @@
| 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 | 16 tests in tests/unit and tests/integration use a fake driver, no network, injected sleep. |
| 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. |
@@ -47,7 +47,7 @@
## Overall Verdict
Go — Every Mandatory criterion passes. Optional criterion 13 was fixed (pinned versions).
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
+17 -11
View File
@@ -9,28 +9,34 @@ 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}/{{account}}"
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"
## Login form locators (verified against the live mock).
## 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')]"
## Locators below are UNVERIFIED against the live mock: they use broad
# contains(text(), ...) matches so they survive small DOM differences.
## 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')]"
)
NOT_NOW_BUTTON_XPATH = "//button[contains(text(), 'Not now') or contains(text(), 'Not Now')]"
FOLLOWERS_LINK_XPATH = "//a[contains(@href, 'followers') or contains(text(), 'followers')]"
FOLLOWERS_MODAL_XPATH = "//div[@role='dialog' or contains(@class, 'modal')]"
FOLLOW_BUTTON_XPATH = ".//button[contains(text(), 'Follow') and not(contains(text(), 'Following'))]"
CANCEL_BUTTON_XPATH = "//button[contains(text(), 'Cancel')]"
## Timing, in seconds.
FOLLOW_DELAY = 1
@@ -38,7 +44,7 @@ SCROLL_DELAY = 1
PAGE_SETTLE_DELAY = 2
WAIT_TIMEOUT = 10
## Number of times the followers modal is scrolled to load more entries.
## 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_MODAL_SCRIPT = "arguments[0].scrollTop = arguments[0].scrollHeight"
SCROLL_CONTAINER_SCRIPT = "arguments[0].scrollTop = arguments[0].scrollHeight"
@@ -81,11 +81,11 @@ class InstaFollower:
)
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.
# "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 modal.
## @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)
@@ -94,20 +94,21 @@ class InstaFollower:
wait.until(
EC.presence_of_element_located((By.XPATH, constants.FOLLOWERS_LINK_XPATH))
).click()
modal = wait.until(
EC.presence_of_element_located((By.XPATH, constants.FOLLOWERS_MODAL_XPATH))
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):
# Scroll the modal element itself; the page behind it does not scroll.
self._driver.execute_script(constants.SCROLL_MODAL_SCRIPT, modal)
# 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 modal.
## @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:
modal = self._driver.find_element(By.XPATH, constants.FOLLOWERS_MODAL_XPATH)
for button in modal.find_elements(By.XPATH, constants.FOLLOW_BUTTON_XPATH):
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:
@@ -20,7 +20,8 @@ def test_run_bot_drives_selenium_follower_through_all_steps(
constants.PASSWORD_FIELD_ID: [FakeElement()],
constants.SUBMIT_BUTTON_XPATH: [FakeElement()],
constants.FOLLOWERS_LINK_XPATH: [FakeElement()],
constants.FOLLOWERS_MODAL_XPATH: [FakeElement(children=[button])],
constants.FOLLOWERS_MODAL_XPATH: [FakeElement()],
constants.FOLLOWERS_SCROLL_XPATH: [FakeElement(children=[button])],
}
)
fake: Any = driver
@@ -55,21 +55,29 @@ def test_login_raises_when_credentials_missing(monkeypatch: pytest.MonkeyPatch)
make_bot(FakeDriver({}), []).login()
def test_find_followers_scrolls_the_modal_n_times() -> None:
modal, link = FakeElement(), FakeElement()
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_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_MODAL_SCRIPT, modal)] * constants.SCROLL_COUNT
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_MODAL_XPATH: [FakeElement(children=buttons)]})
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]
@@ -81,7 +89,7 @@ def test_follow_cancels_popup_and_continues_when_click_intercepted() -> None:
buttons = [FakeElement(), FakeElement(intercepted=True), FakeElement()]
driver = FakeDriver(
{
constants.FOLLOWERS_MODAL_XPATH: [FakeElement(children=buttons)],
constants.FOLLOWERS_SCROLL_XPATH: [FakeElement(children=buttons)],
constants.CANCEL_BUTTON_XPATH: [cancel],
}
)
@@ -122,3 +130,21 @@ def test_context_exit_quits_owned_driver_when_body_raises(
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