diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..b58955c --- /dev/null +++ b/pytest.ini @@ -0,0 +1,5 @@ +[pytest] +minversion = 6.0 +addopts = --strict-markers --tb=short --cov=src --cov-report=term-missing +testpaths = + tests \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 9f271ac..70cdb62 100644 --- a/requirements.txt +++ b/requirements.txt @@ -24,3 +24,4 @@ jsonschema-specifications==2023.12.1 httpx~=0.27.2 python-dotenv~=1.0.1 PyYAML~=6.0.2 +pytest>=8.3.3 diff --git a/src/linkedIn_easy_applier.py b/src/linkedIn_easy_applier.py index cf64245..1d734e8 100644 --- a/src/linkedIn_easy_applier.py +++ b/src/linkedIn_easy_applier.py @@ -76,6 +76,20 @@ class LinkedInEasyApplier: logger.error("Failed to return to job page after %d attempts. Cannot apply for the job.", max_attempts) raise Exception( f"Redirected to LinkedIn Premium page and failed to return after {max_attempts} attempts. Job application aborted.") + + def apply_to_job(self, job: Any) -> None: + """ + Starts the process of applying to a job. + :param job: A job object with the job details. + :return: None + """ + logger.debug(f"Applying to job: {job}") + try: + self.job_apply(job) + logger.info(f"Successfully applied to job: {job.title}") + except Exception as e: + logger.error(f"Failed to apply to job: {job.title}, error: {str(e)}") + raise e def job_apply(self, job: Any): logger.debug("Starting job application for job: %s", job) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_job_application_profile.py b/tests/test_job_application_profile.py new file mode 100644 index 0000000..91cc2a6 --- /dev/null +++ b/tests/test_job_application_profile.py @@ -0,0 +1,153 @@ +import pytest +from src.job_application_profile import JobApplicationProfile + +@pytest.fixture +def valid_yaml(): + """Valid YAML string for initializing JobApplicationProfile.""" + return """ + self_identification: + gender: Male + pronouns: He/Him + veteran: No + disability: No + ethnicity: Asian + legal_authorization: + eu_work_authorization: "Yes" + us_work_authorization: "Yes" + requires_us_visa: "No" + legally_allowed_to_work_in_us: "Yes" + requires_us_sponsorship: "No" + requires_eu_visa: "No" + legally_allowed_to_work_in_eu: "Yes" + requires_eu_sponsorship: "No" + work_preferences: + remote_work: "Yes" + in_person_work: "No" + open_to_relocation: "Yes" + willing_to_complete_assessments: "Yes" + willing_to_undergo_drug_tests: "Yes" + willing_to_undergo_background_checks: "Yes" + availability: + notice_period: "2 weeks" + salary_expectations: + salary_range_usd: "80000-120000" + """ + +@pytest.fixture +def missing_field_yaml(): + """YAML string missing a required field (self_identification).""" + return """ + legal_authorization: + eu_work_authorization: "Yes" + us_work_authorization: "Yes" + requires_us_visa: "No" + legally_allowed_to_work_in_us: "Yes" + requires_us_sponsorship: "No" + requires_eu_visa: "No" + legally_allowed_to_work_in_eu: "Yes" + requires_eu_sponsorship: "No" + work_preferences: + remote_work: "Yes" + in_person_work: "No" + open_to_relocation: "Yes" + willing_to_complete_assessments: "Yes" + willing_to_undergo_drug_tests: "Yes" + willing_to_undergo_background_checks: "Yes" + availability: + notice_period: "2 weeks" + salary_expectations: + salary_range_usd: "80000-120000" + """ + +@pytest.fixture +def invalid_type_yaml(): + """YAML string with an invalid type for a field.""" + return """ + self_identification: + gender: Male + pronouns: He/Him + veteran: No + disability: No + ethnicity: Asian + legal_authorization: + eu_work_authorization: "Yes" + us_work_authorization: "Yes" + requires_us_visa: "No" + legally_allowed_to_work_in_us: "Yes" + requires_us_sponsorship: "No" + requires_eu_visa: "No" + legally_allowed_to_work_in_eu: "Yes" + requires_eu_sponsorship: "No" + work_preferences: + remote_work: 12345 # Invalid type, expecting a string + in_person_work: "No" + open_to_relocation: "Yes" + willing_to_complete_assessments: "Yes" + willing_to_undergo_drug_tests: "Yes" + willing_to_undergo_background_checks: "Yes" + availability: + notice_period: "2 weeks" + salary_expectations: + salary_range_usd: "80000-120000" + """ + +def test_initialize_with_valid_yaml(valid_yaml): + """Test initializing JobApplicationProfile with valid YAML.""" + profile = JobApplicationProfile(valid_yaml) + + # Check that the profile fields are correctly initialized + assert profile.self_identification.gender == "Male" + assert profile.self_identification.pronouns == "He/Him" + assert profile.legal_authorization.eu_work_authorization == "Yes" + assert profile.work_preferences.remote_work == "Yes" + assert profile.availability.notice_period == "2 weeks" + assert profile.salary_expectations.salary_range_usd == "80000-120000" + +def test_initialize_with_missing_field(missing_field_yaml): + """Test initializing JobApplicationProfile with missing required fields.""" + with pytest.raises(KeyError) as excinfo: + JobApplicationProfile(missing_field_yaml) + assert "self_identification" in str(excinfo.value) + +def test_initialize_with_invalid_yaml(): + """Test initializing JobApplicationProfile with invalid YAML.""" + invalid_yaml_str = """ + self_identification: + gender: Male + pronouns: He/Him + veteran: No + disability: No + ethnicity: Asian + legal_authorization: + eu_work_authorization: "Yes" + us_work_authorization: "Yes" + requires_us_visa: "No" + legally_allowed_to_work_in_us: "Yes" + requires_us_sponsorship: "No" + requires_eu_visa: "No" + legally_allowed_to_work_in_eu: "Yes" + requires_eu_sponsorship: "No" + work_preferences: + remote_work: "Yes" + in_person_work: "No" + availability: + notice_period: "2 weeks" + salary_expectations: + salary_range_usd: "80000-120000" + """ # Missing fields in work_preferences + + with pytest.raises(TypeError): + JobApplicationProfile(invalid_yaml_str) + +def test_str_representation(valid_yaml): + """Test the string representation of JobApplicationProfile.""" + profile = JobApplicationProfile(valid_yaml) + profile_str = str(profile) + + assert "Self Identification:" in profile_str + assert "Legal Authorization:" in profile_str + assert "Work Preferences:" in profile_str + assert "Availability:" in profile_str + assert "Salary Expectations:" in profile_str + assert "Male" in profile_str + assert "80000-120000" in profile_str diff --git a/tests/test_linkedIn_authenticator.py b/tests/test_linkedIn_authenticator.py new file mode 100644 index 0000000..6277fc0 --- /dev/null +++ b/tests/test_linkedIn_authenticator.py @@ -0,0 +1,158 @@ +import pytest +from selenium.webdriver.common.by import By +from selenium.webdriver.support.ui import WebDriverWait +from selenium.webdriver.support import expected_conditions as EC +from src.linkedIn_authenticator import LinkedInAuthenticator +from selenium.common.exceptions import NoSuchElementException, TimeoutException + + +@pytest.fixture +def mock_driver(mocker): + """Fixture to mock the Selenium WebDriver.""" + return mocker.Mock() + + +@pytest.fixture +def authenticator(mock_driver): + """Fixture to initialize LinkedInAuthenticator with a mocked driver.""" + return LinkedInAuthenticator(mock_driver) + + +def test_set_secrets(authenticator): + """Test setting secrets (email, password).""" + authenticator.set_secrets("test@example.com", "password123") + assert authenticator.email == "test@example.com" + assert authenticator.password == "password123" + + +def test_start_logged_in(mocker, authenticator): + """Test starting LinkedIn when already logged in.""" + mocker.patch.object(authenticator, 'is_logged_in', return_value=True) + mocker.patch.object(authenticator.driver, 'get') + mocker.patch("time.sleep") # Avoid waiting during the test + + authenticator.start() + + authenticator.driver.get.assert_called_with('https://www.linkedin.com/feed') + authenticator.is_logged_in.assert_called_once() + assert authenticator.driver.get.call_count == 1 + + +def test_start_not_logged_in(mocker, authenticator): + """Test starting LinkedIn when not logged in.""" + mocker.patch.object(authenticator, 'is_logged_in', return_value=False) + mocker.patch.object(authenticator, 'handle_login') + mocker.patch.object(authenticator.driver, 'get') + mocker.patch("time.sleep") + + authenticator.start() + + authenticator.driver.get.assert_called_with('https://www.linkedin.com/feed') + authenticator.handle_login.assert_called_once() + + +def test_handle_login(mocker, authenticator): + """Test handling the LinkedIn login process.""" + mocker.patch.object(authenticator.driver, 'get') + mocker.patch.object(authenticator, 'enter_credentials') + mocker.patch.object(authenticator, 'submit_login_form') + mocker.patch.object(authenticator, 'handle_security_check') + + # Mock current_url as a regular return value, not PropertyMock + mocker.patch.object(authenticator.driver, 'current_url', return_value='https://www.linkedin.com/login') + + authenticator.handle_login() + + authenticator.driver.get.assert_called_with('https://www.linkedin.com/login') + authenticator.enter_credentials.assert_called_once() + authenticator.submit_login_form.assert_called_once() + authenticator.handle_security_check.assert_called_once() + + +def test_enter_credentials_success(mocker, authenticator): + """Test entering credentials.""" + email_mock = mocker.Mock() + password_mock = mocker.Mock() + + mocker.patch.object(WebDriverWait, 'until', return_value=email_mock) + mocker.patch.object(authenticator.driver, 'find_element', return_value=password_mock) + + authenticator.set_secrets("test@example.com", "password123") + authenticator.enter_credentials() + + email_mock.send_keys.assert_called_once_with("test@example.com") + password_mock.send_keys.assert_called_once_with("password123") + + +def test_enter_credentials_timeout(mocker, authenticator): + """Test entering credentials with a TimeoutException.""" + mocker.patch.object(WebDriverWait, 'until', side_effect=TimeoutException) + + authenticator.set_secrets("test@example.com", "password123") + + authenticator.enter_credentials() + + authenticator.driver.find_element.assert_not_called() # Password input should not be accessed if email fails + + +def test_submit_login_form_success(mocker, authenticator): + """Test submitting the login form.""" + login_button_mock = mocker.Mock() + mocker.patch.object(authenticator.driver, 'find_element', return_value=login_button_mock) + + authenticator.submit_login_form() + + login_button_mock.click.assert_called_once() + + +def test_submit_login_form_no_button(mocker, authenticator): + """Test submitting the login form when the login button is not found.""" + mocker.patch.object(authenticator.driver, 'find_element', side_effect=NoSuchElementException) + + authenticator.submit_login_form() + + authenticator.driver.find_element.assert_called_once_with(By.XPATH, '//button[@type="submit"]') + + +def test_is_logged_in_true(mocker, authenticator): + """Test if the user is logged in.""" + buttons_mock = mocker.Mock() + buttons_mock.text = "Start a post" + mocker.patch.object(WebDriverWait, 'until') + mocker.patch.object(authenticator.driver, 'find_elements', return_value=[buttons_mock]) + + assert authenticator.is_logged_in() is True + + +def test_is_logged_in_false(mocker, authenticator): + """Test if the user is not logged in.""" + mocker.patch.object(WebDriverWait, 'until') + mocker.patch.object(authenticator.driver, 'find_elements', return_value=[]) + + assert authenticator.is_logged_in() is False + + +def test_handle_security_check_success(mocker, authenticator): + """Test handling security check successfully.""" + mocker.patch.object(WebDriverWait, 'until', side_effect=[ + mocker.Mock(), # Security checkpoint detection + mocker.Mock() # Security check completion + ]) + + authenticator.handle_security_check() + + # Verify WebDriverWait is called with EC.url_contains for both the challenge and feed + WebDriverWait(authenticator.driver, 10).until.assert_any_call(mocker.ANY) + WebDriverWait(authenticator.driver, 300).until.assert_any_call(mocker.ANY) + + + +def test_handle_security_check_timeout(mocker, authenticator): + """Test handling security check timeout.""" + mocker.patch.object(WebDriverWait, 'until', side_effect=TimeoutException) + + authenticator.handle_security_check() + + # Verify WebDriverWait is called with EC.url_contains for the challenge + WebDriverWait(authenticator.driver, 10).until.assert_any_call(mocker.ANY) + diff --git a/tests/test_linkedIn_bot_facade.py b/tests/test_linkedIn_bot_facade.py new file mode 100644 index 0000000..787d99a --- /dev/null +++ b/tests/test_linkedIn_bot_facade.py @@ -0,0 +1,14 @@ +import pytest +# from src.linkedIn_job_manager import JobManager + +@pytest.fixture +def job_manager(): + """Fixture for JobManager.""" + return None # Replace with valid instance or mock later + +def test_bot_functionality(job_manager): + """Test LinkedIn bot facade.""" + # Example: test job manager interacts with the bot facade correctly + job = {"title": "Software Engineer"} + # job_manager.some_method_to_apply(job) + assert job is not None # Placeholder for actual test diff --git a/tests/test_linkedIn_easy_applier.py b/tests/test_linkedIn_easy_applier.py new file mode 100644 index 0000000..7000a41 --- /dev/null +++ b/tests/test_linkedIn_easy_applier.py @@ -0,0 +1,97 @@ +import pytest +from unittest import mock +from src.linkedIn_easy_applier import LinkedInEasyApplier + + +@pytest.fixture +def mock_driver(): + """Fixture to mock Selenium WebDriver.""" + return mock.Mock() + + +@pytest.fixture +def mock_gpt_answerer(): + """Fixture to mock GPT Answerer.""" + return mock.Mock() + + +@pytest.fixture +def mock_resume_generator_manager(): + """Fixture to mock Resume Generator Manager.""" + return mock.Mock() + + +@pytest.fixture +def easy_applier(mock_driver, mock_gpt_answerer, mock_resume_generator_manager): + """Fixture to initialize LinkedInEasyApplier with mocks.""" + return LinkedInEasyApplier( + driver=mock_driver, + resume_dir="/path/to/resume", + set_old_answers=[('Question 1', 'Answer 1', 'Type 1')], + gpt_answerer=mock_gpt_answerer, + resume_generator_manager=mock_resume_generator_manager + ) + + +def test_initialization(mocker, easy_applier): + """Test that LinkedInEasyApplier is initialized correctly.""" + # Mock os.path.exists to return True + mocker.patch('os.path.exists', return_value=True) + + easy_applier = LinkedInEasyApplier( + driver=mocker.Mock(), + resume_dir="/path/to/resume", + set_old_answers=[('Question 1', 'Answer 1', 'Type 1')], + gpt_answerer=mocker.Mock(), + resume_generator_manager=mocker.Mock() + ) + + assert easy_applier.resume_path == "/path/to/resume" + assert len(easy_applier.set_old_answers) == 1 + assert easy_applier.gpt_answerer is not None + assert easy_applier.resume_generator_manager is not None + + +def test_apply_to_job_success(mocker, easy_applier): + """Test successfully applying to a job.""" + mock_job = mock.Mock() + + # Mock job_apply so we don't actually try to apply + mocker.patch.object(easy_applier, 'job_apply') + + easy_applier.apply_to_job(mock_job) + easy_applier.job_apply.assert_called_once_with(mock_job) + + +def test_apply_to_job_failure(mocker, easy_applier): + """Test failure while applying to a job.""" + mock_job = mock.Mock() + mocker.patch.object(easy_applier, 'job_apply', + side_effect=Exception("Test error")) + + with pytest.raises(Exception, match="Test error"): + easy_applier.apply_to_job(mock_job) + + easy_applier.job_apply.assert_called_once_with(mock_job) + + +def test_check_for_premium_redirect_no_redirect(mocker, easy_applier): + """Test that check_for_premium_redirect works when there's no redirect.""" + mock_job = mock.Mock() + easy_applier.driver.current_url = "https://www.linkedin.com/jobs/view/1234" + + easy_applier.check_for_premium_redirect(mock_job) + easy_applier.driver.get.assert_not_called() + + +def test_check_for_premium_redirect_with_redirect(mocker, easy_applier): + """Test that check_for_premium_redirect handles LinkedIn Premium redirects.""" + mock_job = mock.Mock() + easy_applier.driver.current_url = "https://www.linkedin.com/premium" + mock_job.link = "https://www.linkedin.com/jobs/view/1234" + + with pytest.raises(Exception, match="Redirected to LinkedIn Premium page and failed to return"): + easy_applier.check_for_premium_redirect(mock_job) + + # Verify that it attempted to return to the job page 3 times + assert easy_applier.driver.get.call_count == 3 diff --git a/tests/test_linkedIn_job_manager.py b/tests/test_linkedIn_job_manager.py new file mode 100644 index 0000000..0b4121e --- /dev/null +++ b/tests/test_linkedIn_job_manager.py @@ -0,0 +1,168 @@ +from src.job import Job +from unittest import mock +from pathlib import Path +import os +import pytest +from src.linkedIn_job_manager import LinkedInJobManager +from selenium.common.exceptions import NoSuchElementException + + +@pytest.fixture +def job_manager(mocker): + """Fixture to create a LinkedInJobManager instance with mocked driver.""" + mock_driver = mocker.Mock() + return LinkedInJobManager(mock_driver) + + +def test_initialization(job_manager): + """Test LinkedInJobManager initialization.""" + assert job_manager.driver is not None + assert job_manager.set_old_answers == set() + assert job_manager.easy_applier_component is None + + +def test_set_parameters(mocker, job_manager): + """Test setting parameters for the LinkedInJobManager.""" + # Mocking os.path.exists to return True for the resume path + mocker.patch('pathlib.Path.exists', return_value=True) + + params = { + 'company_blacklist': ['Company A', 'Company B'], + 'title_blacklist': ['Intern', 'Junior'], + 'positions': ['Software Engineer', 'Data Scientist'], + 'locations': ['New York', 'San Francisco'], + 'apply_once_at_company': True, + 'uploads': {'resume': '/path/to/resume'}, # Resume path provided here + 'outputFileDirectory': '/path/to/output', + 'job_applicants_threshold': { + 'min_applicants': 5, + 'max_applicants': 50 + }, + 'remote': False, + 'distance': 50, + 'date': {'all time': True} + } + + job_manager.set_parameters(params) + + # Normalize paths to handle platform differences (e.g., Windows vs Unix-like systems) + assert str(job_manager.resume_path) == os.path.normpath('/path/to/resume') + assert str(job_manager.output_file_directory) == os.path.normpath( + '/path/to/output') + + +def next_job_page(self, position, location, job_page): + logger.debug("Navigating to next job page: %s in %s, page %d", + position, location, job_page) + self.driver.get( + f"https://www.linkedin.com/jobs/search/{self.base_search_url}&keywords={position}&location={location}&start={job_page * 25}") + + +def test_get_jobs_from_page_no_jobs(mocker, job_manager): + """Test get_jobs_from_page when no jobs are found.""" + mocker.patch.object(job_manager.driver, 'find_element', + side_effect=NoSuchElementException) + + jobs = job_manager.get_jobs_from_page() + assert jobs == [] + + +def test_get_jobs_from_page_with_jobs(mocker, job_manager): + """Test get_jobs_from_page when job elements are found.""" + # Mock the no_jobs_element to behave correctly + mock_no_jobs_element = mocker.Mock() + mock_no_jobs_element.text = "No matching jobs found" + + # Mocking the find_element to return the mock no_jobs_element + mocker.patch.object(job_manager.driver, 'find_element', + return_value=mock_no_jobs_element) + + # Mock the page_source + mocker.patch.object(job_manager.driver, 'page_source', + return_value="some page content") + + # Ensure jobs are returned as empty list due to "No matching jobs found" + jobs = job_manager.get_jobs_from_page() + assert jobs == [] # No jobs expected due to "No matching jobs found" + + +def test_apply_jobs_with_no_jobs(mocker, job_manager): + """Test apply_jobs when no jobs are found.""" + # Mocking find_element to return a mock element that simulates no jobs + mock_element = mocker.Mock() + mock_element.text = "No matching jobs found" + + # Mock the driver to simulate the page source + mocker.patch.object(job_manager.driver, 'page_source', return_value="") + + # Mock the driver to return the mock element when find_element is called + mocker.patch.object(job_manager.driver, 'find_element', + return_value=mock_element) + + # Call apply_jobs and ensure no exceptions are raised + job_manager.apply_jobs() + + # Ensure it attempted to find the job results list + assert job_manager.driver.find_element.call_count == 1 + + +def test_apply_jobs_with_jobs(mocker, job_manager): + """Test apply_jobs when jobs are present.""" + + # Mock no_jobs_element to simulate the absence of "No matching jobs found" banner + no_jobs_element = mocker.Mock() + no_jobs_element.text = "" # Empty text means "No matching jobs found" is not present + mocker.patch.object(job_manager.driver, 'find_element', + return_value=no_jobs_element) + + # Mock the page_source to simulate what the page looks like when jobs are present + mocker.patch.object(job_manager.driver, 'page_source', + return_value="some job content") + + # Mock the outer find_elements (scaffold-layout__list-container) + container_mock = mocker.Mock() + + # Mock the inner find_elements to return job list items + job_element_mock = mocker.Mock() + # Simulating two job items + job_elements_list = [job_element_mock, job_element_mock] + + # Return the container mock, which itself returns the job elements list + container_mock.find_elements.return_value = job_elements_list + mocker.patch.object(job_manager.driver, 'find_elements', + return_value=[container_mock]) + + # Mock the extract_job_information_from_tile method to return sample job info + mocker.patch.object(job_manager, 'extract_job_information_from_tile', return_value=( + "Title", "Company", "Location", "Apply", "Link")) + + # Mock other methods like is_blacklisted, is_already_applied_to_job, and is_already_applied_to_company + mocker.patch.object(job_manager, 'is_blacklisted', return_value=False) + mocker.patch.object( + job_manager, 'is_already_applied_to_job', return_value=False) + mocker.patch.object( + job_manager, 'is_already_applied_to_company', return_value=False) + + # Mock the LinkedInEasyApplier component + job_manager.easy_applier_component = mocker.Mock() + + # Mock the output_file_directory as a valid Path object + job_manager.output_file_directory = Path("/mocked/path/to/output") + + # Mock Path.exists() to always return True (so no actual file system interaction is needed) + mocker.patch.object(Path, 'exists', return_value=True) + + # Mock the open function to prevent actual file writing + mock_open = mocker.mock_open() + mocker.patch('builtins.open', mock_open) + + # Run the apply_jobs method + job_manager.apply_jobs() + + # Assertions + assert job_manager.driver.find_elements.call_count == 1 + # Called for each job element + assert job_manager.extract_job_information_from_tile.call_count == 2 + # Called for each job element + assert job_manager.easy_applier_component.job_apply.call_count == 2 + mock_open.assert_called() # Ensure that the open function was called diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..efe3645 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,96 @@ +# tests/test_utils.py +import pytest +import os +import time +from unittest import mock +from selenium.webdriver.remote.webelement import WebElement +from src.utils import ensure_chrome_profile, is_scrollable, scroll_slow, chrome_browser_options, printred, printyellow + +# Mocking logging to avoid actual file writing +@pytest.fixture(autouse=True) +def mock_logger(mocker): + mocker.patch("src.utils.logger") + +# Test ensure_chrome_profile function +def test_ensure_chrome_profile(mocker): + mocker.patch("os.path.exists", return_value=False) # Pretend directory doesn't exist + mocker.patch("os.makedirs") # Mock making directories + + # Call the function + profile_path = ensure_chrome_profile() + + # Verify that os.makedirs was called twice to create the directory + assert profile_path.endswith("linkedin_profile") + assert os.path.exists.called + assert os.makedirs.called + +# Test is_scrollable function +def test_is_scrollable(mocker): + mock_element = mocker.Mock(spec=WebElement) + mock_element.get_attribute.side_effect = lambda attr: "1000" if attr == "scrollHeight" else "500" + + # Call the function + scrollable = is_scrollable(mock_element) + + # Check the expected outcome + assert scrollable is True + mock_element.get_attribute.assert_any_call("scrollHeight") + mock_element.get_attribute.assert_any_call("clientHeight") + +# Test scroll_slow function +def test_scroll_slow(mocker): + mock_driver = mocker.Mock() + mock_element = mocker.Mock(spec=WebElement) + + # Mock element's attributes for scrolling + mock_element.get_attribute.side_effect = lambda attr: "2000" if attr == "scrollHeight" else "0" + mock_element.is_displayed.return_value = True + mocker.patch("time.sleep") # Mock time.sleep to avoid waiting + + # Call the function + scroll_slow(mock_driver, mock_element, start=0, end=1000, step=100, reverse=False) + + # Ensure that scrolling happened multiple times + assert mock_driver.execute_script.called + mock_element.is_displayed.assert_called_once() + +def test_scroll_slow_element_not_scrollable(mocker): + mock_driver = mocker.Mock() + mock_element = mocker.Mock(spec=WebElement) + + # Mock the attributes so the element is not scrollable + mock_element.get_attribute.side_effect = lambda attr: "1000" if attr == "scrollHeight" else "1000" + mock_element.is_displayed.return_value = True + + scroll_slow(mock_driver, mock_element, start=0, end=1000, step=100) + + # Ensure it detected non-scrollable element + mock_driver.execute_script.assert_not_called() + +# Test chrome_browser_options function +def test_chrome_browser_options(mocker): + mocker.patch("src.utils.ensure_chrome_profile") + mocker.patch("os.path.dirname", return_value="/mocked/path") + mocker.patch("os.path.basename", return_value="profile_directory") + + mock_options = mocker.Mock() + + mocker.patch("selenium.webdriver.ChromeOptions", return_value=mock_options) + + # Call the function + options = chrome_browser_options() + + # Ensure options were set + assert mock_options.add_argument.called + assert options == mock_options + +# Test printred and printyellow functions +def test_printred(mocker): + mocker.patch("builtins.print") + printred("Test") + print.assert_called_once_with("\033[91mTest\033[0m") + +def test_printyellow(mocker): + mocker.patch("builtins.print") + printyellow("Test") + print.assert_called_once_with("\033[93mTest\033[0m")