refactor: file structure change to multiple files
This commit is contained in:
parent
9bca740133
commit
abdcce6042
0
civicrm_tester/__init__.py
Normal file
0
civicrm_tester/__init__.py
Normal file
67
civicrm_tester/activities_tab.py
Normal file
67
civicrm_tester/activities_tab.py
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
from selenium.webdriver.common.by import By
|
||||||
|
from selenium.webdriver.support.select import Select
|
||||||
|
|
||||||
|
from .base import BaseTester
|
||||||
|
|
||||||
|
|
||||||
|
class ActivitiesTab(BaseTester):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.title("Activities Tab")
|
||||||
|
self.desc("Testing if a contacts activities tab displays all activies")
|
||||||
|
self.contact_page = self.base_url + "/civicrm/contact/view/?reset=1&cid={}"
|
||||||
|
|
||||||
|
def _test(self, cid: str):
|
||||||
|
self.debug("loading contact page for CID %s" % cid)
|
||||||
|
self.browser.get(self.contact_page.format(cid))
|
||||||
|
self.wait_until_visible((By.ID, "ui-id-10"))
|
||||||
|
# Contact page as loaded
|
||||||
|
activities_tab_button = self.find_element_by_id("ui-id-10")
|
||||||
|
num_element = activities_tab_button.find_element_by_tag_name("em")
|
||||||
|
num_of_activ = int(num_element.text)
|
||||||
|
activities_tab_button.click()
|
||||||
|
table_row_selector = (
|
||||||
|
By.CSS_SELECTOR, "#DataTables_Table_0 > tbody tr"
|
||||||
|
)
|
||||||
|
self.debug(
|
||||||
|
"clicked activities button and waiting for activities page to load"
|
||||||
|
)
|
||||||
|
self.wait_until_visible(table_row_selector)
|
||||||
|
# Activities page as now loaded
|
||||||
|
table_length_dropdown = Select(
|
||||||
|
self.browser.find_element_by_xpath(
|
||||||
|
"//select[@name='DataTables_Table_0_length']"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
table_length_dropdown.select_by_visible_text("100")
|
||||||
|
self.wait_until_clickable(table_row_selector)
|
||||||
|
num_of_rows = len(
|
||||||
|
self.browser.find_elements_by_css_selector("tr.crm-entity")
|
||||||
|
)
|
||||||
|
if num_of_activ == num_of_rows:
|
||||||
|
self.passed(
|
||||||
|
"expected number of activities found in activity table for CID %s. Expected: %d, Actual %d"
|
||||||
|
% (cid, num_of_activ, num_of_rows)
|
||||||
|
)
|
||||||
|
elif num_of_activ > 100 and num_of_rows == 100:
|
||||||
|
self.issue(
|
||||||
|
"Number of activities for CID %s is above 100. This is the max amount the table can display. The table is displaying 100 entries. Pagination is not supported by the script."
|
||||||
|
% cid
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.failed(
|
||||||
|
"Number of activities is lower than expected for CID %s. Most likely didn't load properly Expected: %d, Actual %d"
|
||||||
|
% (cid, num_of_activ, num_of_rows)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_all_hardcoded_contacts(self):
|
||||||
|
"""Loops self.test with all hardcoded contact ID's
|
||||||
|
Using MP's as MP's are public knowledge and often have activities within the crm system
|
||||||
|
names provided in comments here for debugging purposes
|
||||||
|
"""
|
||||||
|
cid_da = "42219" # Debbie Abrahams
|
||||||
|
cid_kh = "82163" # Kate Hollern
|
||||||
|
cid_na = "42269" # Nigel Addams
|
||||||
|
#cid_db = "43193" Use to test 100 max limit
|
||||||
|
cid_tuple = (cid_da, cid_kh, cid_na)
|
||||||
|
self._test_all(cid_tuple)
|
184
civicrm_tester/base.py
Normal file
184
civicrm_tester/base.py
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import urllib.parse as urlparse
|
||||||
|
from urllib.parse import parse_qs
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from rich.console import Console
|
||||||
|
from rich.logging import RichHandler
|
||||||
|
from selenium import webdriver
|
||||||
|
from selenium.webdriver.common.by import By
|
||||||
|
from selenium.webdriver.support import expected_conditions as EC
|
||||||
|
from selenium.webdriver.support.ui import WebDriverWait
|
||||||
|
|
||||||
|
|
||||||
|
class BaseTester:
|
||||||
|
"""
|
||||||
|
Base class for all test suites
|
||||||
|
|
||||||
|
One caveat: This class defines a pseudo function "_test" which is called by "_test_all"
|
||||||
|
Overloading classes are to define their own tests to be run by selenium after logging in
|
||||||
|
Then they are to call "_test_all" which will run these tests in a loop with given variables
|
||||||
|
This allows the test to be run multiple times with different terms to make sure the test covers all bases.
|
||||||
|
"""
|
||||||
|
def __init__(self, user: str, passwd: str, dev: bool, show_browser: bool):
|
||||||
|
self.console = Console()
|
||||||
|
# Disable pdfminers logger cause its annoying as fuck and provides no value
|
||||||
|
logging.getLogger("pdfminer").setLevel(100)
|
||||||
|
logging.basicConfig(
|
||||||
|
level="INFO",
|
||||||
|
format="%(message)s",
|
||||||
|
datefmt="[%X]",
|
||||||
|
handlers=[RichHandler()]
|
||||||
|
)
|
||||||
|
self.log = logging.getLogger("civiCRM-tester")
|
||||||
|
self.user = user
|
||||||
|
self.passwd = passwd
|
||||||
|
if dev:
|
||||||
|
self.base_url = "https://crm-dev.caat.org.uk"
|
||||||
|
else:
|
||||||
|
self.base_url = "https://crm.caat.org.uk"
|
||||||
|
|
||||||
|
firefox_options = webdriver.FirefoxOptions()
|
||||||
|
firefox_options.headless = not show_browser
|
||||||
|
self.browser = webdriver.Firefox(options=firefox_options)
|
||||||
|
self.wait = WebDriverWait(self.browser, 20)
|
||||||
|
|
||||||
|
def debug(self, msg: str):
|
||||||
|
"""Wrapper for logging debug levels for rich output"""
|
||||||
|
return self.log.debug(msg, extra={"markup": True})
|
||||||
|
|
||||||
|
def info(self, msg: str):
|
||||||
|
"""Wrapper for logging info levels for rich output"""
|
||||||
|
return self.log.info(msg, extra={"markup": True})
|
||||||
|
|
||||||
|
def warn(self, msg: str):
|
||||||
|
"""Wrapper for logging warn levels for rich output"""
|
||||||
|
return self.log.warning(msg, extra={"markup": True})
|
||||||
|
|
||||||
|
def error(self, msg: str):
|
||||||
|
"""Wrapper for logging error levels for rich output"""
|
||||||
|
return self.log.error(msg, extra={"markup": True})
|
||||||
|
|
||||||
|
def critical(self, msg: str):
|
||||||
|
"""Wrapper for logging critical levels for rich output"""
|
||||||
|
return self.log.critical(msg, extra={"markup": True})
|
||||||
|
|
||||||
|
def passed(self, msg: str):
|
||||||
|
"""Wrapper for logging passed tests"""
|
||||||
|
return self.info("[reverse green]PASSED[/reverse green] %s" % msg)
|
||||||
|
|
||||||
|
def issue(self, msg: str):
|
||||||
|
"""
|
||||||
|
Wrapper for logging tests with issues
|
||||||
|
This is used for tests where the result is inconclusive due to some issue that is neither a fail nor pass.
|
||||||
|
Usually requires human interaction
|
||||||
|
"""
|
||||||
|
return self.warn("[reverse yellow]ISSUE[/reverse yellow] %s" % msg)
|
||||||
|
|
||||||
|
def failed(self, msg: str):
|
||||||
|
"""Wrapper for logging failed tests"""
|
||||||
|
return self.info("[reverse red]FAILED[/reverse red] %s" % msg)
|
||||||
|
|
||||||
|
def title(self, test_name: str):
|
||||||
|
"""Create title header for new suite of tests"""
|
||||||
|
return self.info("[cyan]%s[/cyan]" % test_name)
|
||||||
|
|
||||||
|
def desc(self, test_desc: str):
|
||||||
|
"""Log test description of upcoming number of tests"""
|
||||||
|
return self.info("%s" % test_desc)
|
||||||
|
|
||||||
|
def login(self):
|
||||||
|
""" Login to civicrm so we can continue with the proper cookies """
|
||||||
|
self.browser.get(self.base_url)
|
||||||
|
self.debug("Logging in as %s @ %s" % (self.user, self.base_url))
|
||||||
|
username = self.browser.find_element_by_id("edit-name")
|
||||||
|
password = self.browser.find_element_by_id("edit-pass")
|
||||||
|
submit = self.browser.find_element_by_id("edit-submit")
|
||||||
|
username.send_keys(self.user)
|
||||||
|
password.send_keys(self.passwd)
|
||||||
|
submit.click()
|
||||||
|
|
||||||
|
# Wait for the js elements load so we know the cookies are good.
|
||||||
|
# Waits for "Recent Items" part of sidebar which is unique when logged in
|
||||||
|
self.wait.until(
|
||||||
|
EC.visibility_of_element_located((By.ID, "block-civicrm-2"))
|
||||||
|
)
|
||||||
|
self.debug("Successfully logged in as %s" % self.user)
|
||||||
|
|
||||||
|
def logout(self):
|
||||||
|
"""Log browser out of civicrm to reset our test environment"""
|
||||||
|
self.browser.get(self.base_url + "/user/logout")
|
||||||
|
# Wait for the next page to load to finish logging out
|
||||||
|
self.wait.until(
|
||||||
|
EC.visibility_of_element_located((By.ID, "tabs-wrapper"))
|
||||||
|
)
|
||||||
|
|
||||||
|
def _test(self, *args):
|
||||||
|
"""Placeholder to be overwritten by overloading classes"""
|
||||||
|
|
||||||
|
def _test_all(self, test_strings: tuple[str]):
|
||||||
|
"""Loops testing over the given terms"""
|
||||||
|
try:
|
||||||
|
self.login()
|
||||||
|
for term in test_strings:
|
||||||
|
self._test(term)
|
||||||
|
finally:
|
||||||
|
self.logout()
|
||||||
|
self.browser.close()
|
||||||
|
|
||||||
|
def find_element_by_id(self, *args, **kwargs):
|
||||||
|
"""Alias for browser.find_element_by_id"""
|
||||||
|
return self.browser.find_element_by_id(*args, **kwargs)
|
||||||
|
|
||||||
|
def find_element_by_css_selector(self, *args, **kwargs):
|
||||||
|
"""Alias for browser.find_element_by_css_selector"""
|
||||||
|
return self.browser.find_element_by_css_selector(*args, **kwargs)
|
||||||
|
|
||||||
|
def wait_until_visible(self, locator):
|
||||||
|
"""Alias for using inbuilt wait object for wait.until(EC.visibility_of_element_located)"""
|
||||||
|
return self.wait.until(EC.visibility_of_element_located(locator))
|
||||||
|
|
||||||
|
def wait_until_clickable(self, locator):
|
||||||
|
"""Alias for using inbuilt wait object for wait.until(EC.element_to_be_clickable)"""
|
||||||
|
return self.wait.until(EC.element_to_be_clickable(locator))
|
||||||
|
|
||||||
|
|
||||||
|
class SearchExportTester(BaseTester):
|
||||||
|
def _get_export_id(self) -> str:
|
||||||
|
"""Parses url to get the param used to ID what search we are currently doing"""
|
||||||
|
export_page_url = self.browser.current_url
|
||||||
|
parsed = urlparse.urlparse(export_page_url)
|
||||||
|
qf_key = parse_qs(parsed.query)['qfKey']
|
||||||
|
self.debug("got qf_key '%s' from url" % qf_key)
|
||||||
|
return qf_key
|
||||||
|
|
||||||
|
def download(self, data: dict) -> requests.Response:
|
||||||
|
"""
|
||||||
|
Download exports from searches manually using requests
|
||||||
|
This is because managing downloads within Selenium is a nightmare
|
||||||
|
Function tries to replicate the download within the browser as much as possible
|
||||||
|
"""
|
||||||
|
session_cookie = {}
|
||||||
|
for cookie in self.browser.get_cookies():
|
||||||
|
if re.findall(r"^SSESS.*", cookie.get("name")):
|
||||||
|
session_cookie = cookie
|
||||||
|
self.debug("session cookie: %s" % session_cookie)
|
||||||
|
if not session_cookie:
|
||||||
|
self.critical("NO SESSION COOKIE FOUND. Are you logged in?")
|
||||||
|
raise RuntimeError("No session cookie found.")
|
||||||
|
|
||||||
|
session = requests.Session()
|
||||||
|
session.cookies.update(
|
||||||
|
{session_cookie["name"]: session_cookie["value"]}
|
||||||
|
)
|
||||||
|
# Most of this data is replicating the export settings so that the response is the csv data
|
||||||
|
data = {
|
||||||
|
"qfKey": self._get_export_id(),
|
||||||
|
"entryURL": self.base_url + "/civicrm/contact/search",
|
||||||
|
**data # Add provided export data required for specific export requests
|
||||||
|
}
|
||||||
|
res = session.request(
|
||||||
|
"POST", self.base_url + "/civicrm/contact/search", data=data
|
||||||
|
)
|
||||||
|
return res
|
103
civicrm_tester/contact_export.py
Normal file
103
civicrm_tester/contact_export.py
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import re
|
||||||
|
|
||||||
|
from selenium.webdriver.common.by import By
|
||||||
|
from selenium.webdriver.common.keys import Keys
|
||||||
|
|
||||||
|
from .base import SearchExportTester
|
||||||
|
|
||||||
|
|
||||||
|
class ContactExport(SearchExportTester):
|
||||||
|
"""Tests if exporting contacts from a search returns a CSV file with all contacts."""
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.title("Contact Export")
|
||||||
|
self.desc(
|
||||||
|
"Testing if exporting contacts from a search returns a CSV file with all expected contacts."
|
||||||
|
)
|
||||||
|
self.search_url = self.base_url + "/civicrm/contact/search"
|
||||||
|
|
||||||
|
self.contact_selectall_id = "CIVICRM_QFID_ts_all_4"
|
||||||
|
self.contact_dropdown_id = "select2-chosen-4"
|
||||||
|
self.contact_exportoption_id = "select2-result-label-15"
|
||||||
|
|
||||||
|
def download_csv(self):
|
||||||
|
# Data of the request that is specific for this export and not genertic like qr_key
|
||||||
|
data = {
|
||||||
|
"_qf_Select_next": "Continue",
|
||||||
|
"exportOption": 1,
|
||||||
|
"mergeOption": 0,
|
||||||
|
"postal_greeting": 1,
|
||||||
|
"addressee": 1,
|
||||||
|
}
|
||||||
|
return self.download(data)
|
||||||
|
|
||||||
|
def calculate_exported_contacts_number(self) -> int:
|
||||||
|
"""
|
||||||
|
Downloads csv, opens it in a string buffer using StringIO and passes it to the csv lib
|
||||||
|
Counts number of rows (excl. header) and returns that value
|
||||||
|
"""
|
||||||
|
res = self.download_csv()
|
||||||
|
csv_file = io.StringIO(res.text)
|
||||||
|
|
||||||
|
# Dict reader should remove headers
|
||||||
|
exported_csv = csv.DictReader(csv_file)
|
||||||
|
exported_number_exports = sum(1 for row in exported_csv)
|
||||||
|
self.debug(
|
||||||
|
"found %d rows in exported csv excluding header row" %
|
||||||
|
exported_number_exports
|
||||||
|
)
|
||||||
|
|
||||||
|
return exported_number_exports
|
||||||
|
|
||||||
|
def _test(self, search_term: str):
|
||||||
|
"""
|
||||||
|
Test Description:
|
||||||
|
Go to the contact search url
|
||||||
|
Search for the search term
|
||||||
|
Select all contacts and set them to export
|
||||||
|
Get the login cookie and search ID and download the export manually with requests
|
||||||
|
Save the file in tmp
|
||||||
|
Read it to check the number of exported contacts is the same as reported in the UI
|
||||||
|
"""
|
||||||
|
self.browser.get(self.search_url)
|
||||||
|
search_box = self.find_element_by_id("sort_name")
|
||||||
|
search_box.send_keys(search_term)
|
||||||
|
search_box.send_keys(Keys.ENTER)
|
||||||
|
self.debug("searching for contacts with term '%s'" % search_term)
|
||||||
|
self.wait_until_visible(
|
||||||
|
(By.ID, "alpha-filter")
|
||||||
|
) #wait for table to load
|
||||||
|
self.debug("table of results has loaded")
|
||||||
|
|
||||||
|
results_text = self.find_element_by_css_selector(
|
||||||
|
".form-layout-compressed > tbody:nth-child(1) > tr:nth-child(2) > td:nth-child(2) > label:nth-child(2)"
|
||||||
|
).text
|
||||||
|
matches = re.findall(
|
||||||
|
r"(\d+)", results_text
|
||||||
|
) # Should just be one match in normal cases
|
||||||
|
result_no = int(matches[0])
|
||||||
|
|
||||||
|
self.debug("exporting results using the magic dropdown")
|
||||||
|
self.find_element_by_id(self.contact_selectall_id).click()
|
||||||
|
self.find_element_by_id(self.contact_dropdown_id).click()
|
||||||
|
self.find_element_by_id(self.contact_exportoption_id).click()
|
||||||
|
self.wait_until_visible((By.CSS_SELECTOR, ".crm-block"))
|
||||||
|
|
||||||
|
exported_number_exports = self.calculate_exported_contacts_number()
|
||||||
|
if exported_number_exports == (result_no):
|
||||||
|
self.passed(
|
||||||
|
"Number of expected contact exports for '%s' matches actual number of exports - Expected: %d, Actual: %d"
|
||||||
|
% (search_term, result_no, exported_number_exports)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.failed(
|
||||||
|
"Number of expected contact exports for '%s' WAS NOT EQUAL to actual number of exports - Expected: %d, Actual: %d"
|
||||||
|
% (search_term, result_no, exported_number_exports)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_hardcoded_search_terms(self):
|
||||||
|
"""Loops over the test with three hardcoded search terms"""
|
||||||
|
search_terms = ("John", "e", "Smith")
|
||||||
|
self._test_all(search_terms)
|
81
civicrm_tester/steeringcommittee_print_labels.py
Normal file
81
civicrm_tester/steeringcommittee_print_labels.py
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import io
|
||||||
|
import re
|
||||||
|
|
||||||
|
from pdfminer.high_level import extract_text
|
||||||
|
from selenium.webdriver.common.by import By
|
||||||
|
from selenium.webdriver.common.keys import Keys
|
||||||
|
|
||||||
|
from .base import SearchExportTester
|
||||||
|
|
||||||
|
|
||||||
|
class SteeringCommitteePrintLabels(SearchExportTester):
|
||||||
|
"""Tests the pdf labels for the SteeringCommitee show all contacts names"""
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.title("Steering Committee Print Labels")
|
||||||
|
self.desc(
|
||||||
|
"Testing the pdf labels for the SteeringCommittee show all contacts names"
|
||||||
|
)
|
||||||
|
self.search_url = self.base_url + "/civicrm/contact/search"
|
||||||
|
self.group_dropdown = "s2id_autogen2"
|
||||||
|
self.search_button = "_qf_Basic_refresh"
|
||||||
|
self.mail_label_option = "select2-result-label-19"
|
||||||
|
self.contact_selectall_id = "CIVICRM_QFID_ts_all_4"
|
||||||
|
self.contact_dropdown_id = "select2-chosen-4"
|
||||||
|
# Using this to count amount of people in the exported pdf
|
||||||
|
# This will fail if someone is added that doesn't have a UK adddress
|
||||||
|
self.pdf_search_string = "UNITED KINGDOM"
|
||||||
|
|
||||||
|
def _test(self):
|
||||||
|
self.browser.get(self.search_url)
|
||||||
|
group_dropdown = self.find_element_by_id(self.group_dropdown)
|
||||||
|
group_dropdown.click()
|
||||||
|
group_dropdown.send_keys("Steering Committee" + Keys.ENTER)
|
||||||
|
self.find_element_by_id(self.search_button).click()
|
||||||
|
self.wait_until_visible(
|
||||||
|
(By.ID, "alpha-filter")
|
||||||
|
) #wait for table to load
|
||||||
|
self.debug("table of results has loaded")
|
||||||
|
# TODO: Refactor this in base class as a helper function because we do this in another test
|
||||||
|
results_text = self.find_element_by_css_selector(
|
||||||
|
".form-layout-compressed > tbody:nth-child(1) > tr:nth-child(2) > td:nth-child(2) > label:nth-child(2)"
|
||||||
|
).text
|
||||||
|
matches = re.findall(
|
||||||
|
r"(\d+)", results_text
|
||||||
|
) # Should just be one match in normal cases
|
||||||
|
result_no = int(matches[0])
|
||||||
|
self.debug("exporting results using the magic dropdown")
|
||||||
|
self.find_element_by_id(self.contact_selectall_id).click()
|
||||||
|
self.find_element_by_id(self.contact_dropdown_id).click()
|
||||||
|
self.find_element_by_id(self.mail_label_option).click()
|
||||||
|
|
||||||
|
self.wait_until_visible((By.CSS_SELECTOR, ".crm-block"))
|
||||||
|
# By omitting the field, we are effectively disabling the do not mail filter
|
||||||
|
data = {
|
||||||
|
"_qf_default": "Label:submit",
|
||||||
|
# Smallest labels, will show 24 contacts on one page
|
||||||
|
"label_name": "3475",
|
||||||
|
"location_type_id": "",
|
||||||
|
"_qf_Label_submit": "Make+Mailing+Labels"
|
||||||
|
}
|
||||||
|
res = self.download(data)
|
||||||
|
pdf_text = extract_text(io.BytesIO(res.content))
|
||||||
|
label_count = pdf_text.count(self.pdf_search_string)
|
||||||
|
if result_no == label_count:
|
||||||
|
self.passed(
|
||||||
|
"no missing mailing labels from pdf export. Expected: %d, Actual %d"
|
||||||
|
% (result_no, label_count)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.failed(
|
||||||
|
"missing mailing labels from the pdf export. Expected: %d, Actual %d"
|
||||||
|
% (result_no, label_count)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test(self):
|
||||||
|
try:
|
||||||
|
self.login()
|
||||||
|
self._test()
|
||||||
|
finally:
|
||||||
|
self.logout()
|
||||||
|
self.browser.close()
|
430
main.py
430
main.py
@ -1,429 +1,9 @@
|
|||||||
import argparse
|
import argparse
|
||||||
import csv
|
|
||||||
import logging
|
|
||||||
import io
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import urllib.parse as urlparse
|
|
||||||
from urllib.parse import parse_qs
|
|
||||||
|
|
||||||
from pdfminer.high_level import extract_text
|
|
||||||
import requests
|
|
||||||
from rich.logging import RichHandler
|
|
||||||
from rich.console import Console
|
|
||||||
from selenium import webdriver
|
|
||||||
from selenium.webdriver.common.by import By
|
|
||||||
from selenium.webdriver.common.keys import Keys
|
|
||||||
from selenium.webdriver.support import expected_conditions as EC
|
|
||||||
from selenium.webdriver.support.ui import WebDriverWait
|
|
||||||
from selenium.webdriver.support.select import Select
|
|
||||||
|
|
||||||
|
|
||||||
class BaseTester:
|
|
||||||
"""
|
|
||||||
Base class for all test suites
|
|
||||||
|
|
||||||
One caveat: This class defines a pseudo function "_test" which is called by "_test_all"
|
|
||||||
Overloading classes are to define their own tests to be run by selenium after logging in
|
|
||||||
Then they are to call "_test_all" which will run these tests in a loop with given variables
|
|
||||||
This allows the test to be run multiple times with different terms to make sure the test covers all bases.
|
|
||||||
"""
|
|
||||||
def __init__(self, user: str, passwd: str, dev: bool, show_browser: bool):
|
|
||||||
self.console = Console()
|
|
||||||
# Disable pdfminers logger cause its annoying as fuck and provides no value
|
|
||||||
logging.getLogger("pdfminer").setLevel(100)
|
|
||||||
logging.basicConfig(
|
|
||||||
level="INFO",
|
|
||||||
format="%(message)s",
|
|
||||||
datefmt="[%X]",
|
|
||||||
handlers=[RichHandler()]
|
|
||||||
)
|
|
||||||
self.log = logging.getLogger("civiCRM-tester")
|
|
||||||
self.user = user
|
|
||||||
self.passwd = passwd
|
|
||||||
if dev:
|
|
||||||
self.base_url = "https://crm-dev.caat.org.uk"
|
|
||||||
else:
|
|
||||||
self.base_url = "https://crm.caat.org.uk"
|
|
||||||
|
|
||||||
firefox_options = webdriver.FirefoxOptions()
|
|
||||||
firefox_options.headless = not show_browser
|
|
||||||
self.browser = webdriver.Firefox(options=firefox_options)
|
|
||||||
self.wait = WebDriverWait(self.browser, 20)
|
|
||||||
|
|
||||||
def debug(self, msg: str):
|
|
||||||
"""Wrapper for logging debug levels for rich output"""
|
|
||||||
return self.log.debug(msg, extra={"markup": True})
|
|
||||||
|
|
||||||
def info(self, msg: str):
|
|
||||||
"""Wrapper for logging info levels for rich output"""
|
|
||||||
return self.log.info(msg, extra={"markup": True})
|
|
||||||
|
|
||||||
def warn(self, msg: str):
|
|
||||||
"""Wrapper for logging warn levels for rich output"""
|
|
||||||
return self.log.warning(msg, extra={"markup": True})
|
|
||||||
|
|
||||||
def error(self, msg: str):
|
|
||||||
"""Wrapper for logging error levels for rich output"""
|
|
||||||
return self.log.error(msg, extra={"markup": True})
|
|
||||||
|
|
||||||
def critical(self, msg: str):
|
|
||||||
"""Wrapper for logging critical levels for rich output"""
|
|
||||||
return self.log.critical(msg, extra={"markup": True})
|
|
||||||
|
|
||||||
def passed(self, msg: str):
|
|
||||||
"""Wrapper for logging passed tests"""
|
|
||||||
return self.info("[reverse green]PASSED[/reverse green] %s" % msg)
|
|
||||||
|
|
||||||
def issue(self, msg: str):
|
|
||||||
"""
|
|
||||||
Wrapper for logging tests with issues
|
|
||||||
This is used for tests where the result is inconclusive due to some issue that is neither a fail nor pass.
|
|
||||||
Usually requires human interaction
|
|
||||||
"""
|
|
||||||
return self.warn("[reverse yellow]ISSUE[/reverse yellow] %s" % msg)
|
|
||||||
|
|
||||||
def failed(self, msg: str):
|
|
||||||
"""Wrapper for logging failed tests"""
|
|
||||||
return self.info("[reverse red]FAILED[/reverse red] %s" % msg)
|
|
||||||
|
|
||||||
def title(self, test_name: str):
|
|
||||||
"""Create title header for new suite of tests"""
|
|
||||||
return self.info("[cyan]%s[/cyan]" % test_name)
|
|
||||||
|
|
||||||
def desc(self, test_desc: str):
|
|
||||||
"""Log test description of upcoming number of tests"""
|
|
||||||
return self.info("%s" % test_desc)
|
|
||||||
|
|
||||||
def login(self):
|
|
||||||
""" Login to civicrm so we can continue with the proper cookies """
|
|
||||||
self.browser.get(self.base_url)
|
|
||||||
self.debug("Logging in as %s @ %s" % (self.user, self.base_url))
|
|
||||||
username = self.browser.find_element_by_id("edit-name")
|
|
||||||
password = self.browser.find_element_by_id("edit-pass")
|
|
||||||
submit = self.browser.find_element_by_id("edit-submit")
|
|
||||||
username.send_keys(self.user)
|
|
||||||
password.send_keys(self.passwd)
|
|
||||||
submit.click()
|
|
||||||
|
|
||||||
# Wait for the js elements load so we know the cookies are good.
|
|
||||||
# Waits for "Recent Items" part of sidebar which is unique when logged in
|
|
||||||
self.wait.until(
|
|
||||||
EC.visibility_of_element_located((By.ID, "block-civicrm-2"))
|
|
||||||
)
|
|
||||||
self.debug("Successfully logged in as %s" % self.user)
|
|
||||||
|
|
||||||
def logout(self):
|
|
||||||
"""Log browser out of civicrm to reset our test environment"""
|
|
||||||
self.browser.get(self.base_url + "/user/logout")
|
|
||||||
# Wait for the next page to load to finish logging out
|
|
||||||
self.wait.until(
|
|
||||||
EC.visibility_of_element_located((By.ID, "tabs-wrapper"))
|
|
||||||
)
|
|
||||||
|
|
||||||
def _test(self, *args):
|
|
||||||
"""Placeholder to be overwritten by overloading classes"""
|
|
||||||
|
|
||||||
def _test_all(self, test_strings: tuple[str]):
|
|
||||||
"""Loops testing over the given terms"""
|
|
||||||
try:
|
|
||||||
self.login()
|
|
||||||
for term in test_strings:
|
|
||||||
self._test(term)
|
|
||||||
finally:
|
|
||||||
self.logout()
|
|
||||||
self.browser.close()
|
|
||||||
|
|
||||||
def find_element_by_id(self, *args, **kwargs):
|
|
||||||
"""Alias for browser.find_element_by_id"""
|
|
||||||
return self.browser.find_element_by_id(*args, **kwargs)
|
|
||||||
|
|
||||||
def find_element_by_css_selector(self, *args, **kwargs):
|
|
||||||
"""Alias for browser.find_element_by_css_selector"""
|
|
||||||
return self.browser.find_element_by_css_selector(*args, **kwargs)
|
|
||||||
|
|
||||||
def wait_until_visible(self, locator):
|
|
||||||
"""Alias for using inbuilt wait object for wait.until(EC.visibility_of_element_located)"""
|
|
||||||
return self.wait.until(EC.visibility_of_element_located(locator))
|
|
||||||
|
|
||||||
def wait_until_clickable(self, locator):
|
|
||||||
"""Alias for using inbuilt wait object for wait.until(EC.element_to_be_clickable)"""
|
|
||||||
return self.wait.until(EC.element_to_be_clickable(locator))
|
|
||||||
|
|
||||||
|
|
||||||
class SearchExportTester(BaseTester):
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
|
|
||||||
def _get_export_id(self) -> str:
|
|
||||||
"""Parses url to get the param used to ID what search we are currently doing"""
|
|
||||||
export_page_url = self.browser.current_url
|
|
||||||
parsed = urlparse.urlparse(export_page_url)
|
|
||||||
qf_key = parse_qs(parsed.query)['qfKey']
|
|
||||||
self.debug("got qf_key '%s' from url" % qf_key)
|
|
||||||
return qf_key
|
|
||||||
|
|
||||||
def download(self, data: dict) -> requests.Response:
|
|
||||||
"""
|
|
||||||
Download exports from searches manually using requests
|
|
||||||
This is because managing downloads within Selenium is a nightmare
|
|
||||||
Function tries to replicate the download within the browser as much as possible
|
|
||||||
"""
|
|
||||||
session_cookie = {}
|
|
||||||
for cookie in self.browser.get_cookies():
|
|
||||||
if re.findall(r"^SSESS.*", cookie.get("name")):
|
|
||||||
session_cookie = cookie
|
|
||||||
self.debug("session cookie: %s" % session_cookie)
|
|
||||||
if not session_cookie:
|
|
||||||
self.critical("NO SESSION COOKIE FOUND. Are you logged in?")
|
|
||||||
raise RuntimeError("No session cookie found.")
|
|
||||||
|
|
||||||
session = requests.Session()
|
|
||||||
session.cookies.update(
|
|
||||||
{session_cookie["name"]: session_cookie["value"]}
|
|
||||||
)
|
|
||||||
# Most of this data is replicating the export settings so that the response is the csv data
|
|
||||||
data = {
|
|
||||||
"qfKey": self._get_export_id(),
|
|
||||||
"entryURL": self.base_url + "/civicrm/contact/search",
|
|
||||||
**data # Add provided export data required for specific export requests
|
|
||||||
}
|
|
||||||
res = session.request(
|
|
||||||
"POST", self.base_url + "/civicrm/contact/search", data=data
|
|
||||||
)
|
|
||||||
return res
|
|
||||||
|
|
||||||
|
|
||||||
class ContactExport(SearchExportTester):
|
|
||||||
"""Tests if exporting contacts from a search returns a CSV file with all contacts."""
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
self.title("Contact Export")
|
|
||||||
self.desc(
|
|
||||||
"Testing if exporting contacts from a search returns a CSV file with all expected contacts."
|
|
||||||
)
|
|
||||||
self.search_url = self.base_url + "/civicrm/contact/search"
|
|
||||||
|
|
||||||
self.contact_selectall_id = "CIVICRM_QFID_ts_all_4"
|
|
||||||
self.contact_dropdown_id = "select2-chosen-4"
|
|
||||||
self.contact_exportoption_id = "select2-result-label-15"
|
|
||||||
|
|
||||||
def download_csv(self):
|
|
||||||
# Data of the request that is specific for this export and not genertic like qr_key
|
|
||||||
data = {
|
|
||||||
"_qf_Select_next": "Continue",
|
|
||||||
"exportOption": 1,
|
|
||||||
"mergeOption": 0,
|
|
||||||
"postal_greeting": 1,
|
|
||||||
"addressee": 1,
|
|
||||||
}
|
|
||||||
return self.download(data)
|
|
||||||
|
|
||||||
def calculate_exported_contacts_number(self) -> int:
|
|
||||||
"""
|
|
||||||
Downloads csv, opens it in a string buffer using StringIO and passes it to the csv lib
|
|
||||||
Counts number of rows (excl. header) and returns that value
|
|
||||||
"""
|
|
||||||
res = self.download_csv()
|
|
||||||
csv_file = io.StringIO(res.text)
|
|
||||||
|
|
||||||
# Dict reader should remove headers
|
|
||||||
exported_csv = csv.DictReader(csv_file)
|
|
||||||
exported_number_exports = sum(1 for row in exported_csv)
|
|
||||||
self.debug(
|
|
||||||
"found %d rows in exported csv excluding header row" %
|
|
||||||
exported_number_exports
|
|
||||||
)
|
|
||||||
|
|
||||||
return exported_number_exports
|
|
||||||
|
|
||||||
def _test(self, search_term: str):
|
|
||||||
"""
|
|
||||||
Test Description:
|
|
||||||
Go to the contact search url
|
|
||||||
Search for the search term
|
|
||||||
Select all contacts and set them to export
|
|
||||||
Get the login cookie and search ID and download the export manually with requests
|
|
||||||
Save the file in tmp
|
|
||||||
Read it to check the number of exported contacts is the same as reported in the UI
|
|
||||||
"""
|
|
||||||
self.browser.get(self.search_url)
|
|
||||||
search_box = self.find_element_by_id("sort_name")
|
|
||||||
search_box.send_keys(search_term)
|
|
||||||
search_box.send_keys(Keys.ENTER)
|
|
||||||
self.debug("searching for contacts with term '%s'" % search_term)
|
|
||||||
self.wait_until_visible(
|
|
||||||
(By.ID, "alpha-filter")
|
|
||||||
) #wait for table to load
|
|
||||||
self.debug("table of results has loaded")
|
|
||||||
|
|
||||||
results_text = self.find_element_by_css_selector(
|
|
||||||
".form-layout-compressed > tbody:nth-child(1) > tr:nth-child(2) > td:nth-child(2) > label:nth-child(2)"
|
|
||||||
).text
|
|
||||||
matches = re.findall(
|
|
||||||
r"(\d+)", results_text
|
|
||||||
) # Should just be one match in normal cases
|
|
||||||
result_no = int(matches[0])
|
|
||||||
|
|
||||||
self.debug("exporting results using the magic dropdown")
|
|
||||||
self.find_element_by_id(self.contact_selectall_id).click()
|
|
||||||
self.find_element_by_id(self.contact_dropdown_id).click()
|
|
||||||
self.find_element_by_id(self.contact_exportoption_id).click()
|
|
||||||
self.wait_until_visible((By.CSS_SELECTOR, ".crm-block"))
|
|
||||||
|
|
||||||
exported_number_exports = self.calculate_exported_contacts_number()
|
|
||||||
if exported_number_exports == (result_no):
|
|
||||||
self.passed(
|
|
||||||
"Number of expected contact exports for '%s' matches actual number of exports - Expected: %d, Actual: %d"
|
|
||||||
% (search_term, result_no, exported_number_exports)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self.failed(
|
|
||||||
"Number of expected contact exports for '%s' WAS NOT EQUAL to actual number of exports - Expected: %d, Actual: %d"
|
|
||||||
% (search_term, result_no, exported_number_exports)
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_hardcoded_search_terms(self):
|
|
||||||
"""Loops over the test with three hardcoded search terms"""
|
|
||||||
search_terms = ("John", "e", "Smith")
|
|
||||||
self._test_all(search_terms)
|
|
||||||
|
|
||||||
|
|
||||||
class ActivitiesTab(BaseTester):
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
self.title("Activities Tab")
|
|
||||||
self.desc("Testing if a contacts activities tab displays all activies")
|
|
||||||
self.contact_page = self.base_url + "/civicrm/contact/view/?reset=1&cid={}"
|
|
||||||
|
|
||||||
def _test(self, cid: str):
|
|
||||||
self.debug("loading contact page for CID %s" % cid)
|
|
||||||
self.browser.get(self.contact_page.format(cid))
|
|
||||||
self.wait_until_visible((By.ID, "ui-id-10"))
|
|
||||||
# Contact page as loaded
|
|
||||||
activities_tab_button = self.find_element_by_id("ui-id-10")
|
|
||||||
num_element = activities_tab_button.find_element_by_tag_name("em")
|
|
||||||
num_of_activ = int(num_element.text)
|
|
||||||
activities_tab_button.click()
|
|
||||||
table_row_selector = (
|
|
||||||
By.CSS_SELECTOR, "#DataTables_Table_0 > tbody tr"
|
|
||||||
)
|
|
||||||
self.debug(
|
|
||||||
"clicked activities button and waiting for activities page to load"
|
|
||||||
)
|
|
||||||
self.wait_until_visible(table_row_selector)
|
|
||||||
# Activities page as now loaded
|
|
||||||
table_length_dropdown = Select(
|
|
||||||
self.browser.find_element_by_xpath(
|
|
||||||
"//select[@name='DataTables_Table_0_length']"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
table_length_dropdown.select_by_visible_text("100")
|
|
||||||
self.wait_until_clickable(table_row_selector)
|
|
||||||
num_of_rows = len(
|
|
||||||
self.browser.find_elements_by_css_selector("tr.crm-entity")
|
|
||||||
)
|
|
||||||
if num_of_activ == num_of_rows:
|
|
||||||
self.passed(
|
|
||||||
"expected number of activities found in activity table for CID %s. Expected: %d, Actual %d"
|
|
||||||
% (cid, num_of_activ, num_of_rows)
|
|
||||||
)
|
|
||||||
elif num_of_activ > 100 and num_of_rows == 100:
|
|
||||||
self.issue(
|
|
||||||
"Number of activities for CID %s is above 100. This is the max amount the table can display. The table is displaying 100 entries. Pagination is not supported by the script."
|
|
||||||
% cid
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self.failed(
|
|
||||||
"Number of activities is lower than expected for CID %s. Most likely didn't load properly Expected: %d, Actual %d"
|
|
||||||
% (cid, num_of_activ, num_of_rows)
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_all_hardcoded_contacts(self):
|
|
||||||
"""Loops self.test with all hardcoded contact ID's
|
|
||||||
Using MP's as MP's are public knowledge and often have activities within the crm system
|
|
||||||
names provided in comments here for debugging purposes
|
|
||||||
"""
|
|
||||||
cid_da = "42219" # Debbie Abrahams
|
|
||||||
cid_kh = "82163" # Kate Hollern
|
|
||||||
cid_na = "42269" # Nigel Addams
|
|
||||||
#cid_db = "43193" Use to test 100 max limit
|
|
||||||
cid_tuple = (cid_da, cid_kh, cid_na)
|
|
||||||
self._test_all(cid_tuple)
|
|
||||||
|
|
||||||
|
|
||||||
class SteeringCommitteePrintLabels(SearchExportTester):
|
|
||||||
"""Tests the pdf labels for the SteeringCommitee show all contacts names"""
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
self.title("Steering Committee Print Labels")
|
|
||||||
self.desc(
|
|
||||||
"Testing the pdf labels for the SteeringCommittee show all contacts names"
|
|
||||||
)
|
|
||||||
self.search_url = self.base_url + "/civicrm/contact/search"
|
|
||||||
self.group_dropdown = "s2id_autogen2"
|
|
||||||
self.search_button = "_qf_Basic_refresh"
|
|
||||||
self.mail_label_option = "select2-result-label-19"
|
|
||||||
self.contact_selectall_id = "CIVICRM_QFID_ts_all_4"
|
|
||||||
self.contact_dropdown_id = "select2-chosen-4"
|
|
||||||
# Using this to count amount of people in the exported pdf
|
|
||||||
# This will fail if someone is added that doesn't have a UK adddress
|
|
||||||
self.pdf_search_string = "UNITED KINGDOM"
|
|
||||||
|
|
||||||
def _test(self):
|
|
||||||
self.browser.get(self.search_url)
|
|
||||||
group_dropdown = self.find_element_by_id(self.group_dropdown)
|
|
||||||
group_dropdown.click()
|
|
||||||
group_dropdown.send_keys("Steering Committee" + Keys.ENTER)
|
|
||||||
self.find_element_by_id(self.search_button).click()
|
|
||||||
self.wait_until_visible(
|
|
||||||
(By.ID, "alpha-filter")
|
|
||||||
) #wait for table to load
|
|
||||||
self.debug("table of results has loaded")
|
|
||||||
# TODO: Refactor this in base class as a helper function because we do this in another test
|
|
||||||
results_text = self.find_element_by_css_selector(
|
|
||||||
".form-layout-compressed > tbody:nth-child(1) > tr:nth-child(2) > td:nth-child(2) > label:nth-child(2)"
|
|
||||||
).text
|
|
||||||
matches = re.findall(
|
|
||||||
r"(\d+)", results_text
|
|
||||||
) # Should just be one match in normal cases
|
|
||||||
result_no = int(matches[0])
|
|
||||||
self.debug("exporting results using the magic dropdown")
|
|
||||||
self.find_element_by_id(self.contact_selectall_id).click()
|
|
||||||
self.find_element_by_id(self.contact_dropdown_id).click()
|
|
||||||
self.find_element_by_id(self.mail_label_option).click()
|
|
||||||
|
|
||||||
self.wait_until_visible((By.CSS_SELECTOR, ".crm-block"))
|
|
||||||
# By omitting the field, we are effectively disabling the do not mail filter
|
|
||||||
data = {
|
|
||||||
"_qf_default": "Label:submit",
|
|
||||||
# Smallest labels, will show 24 contacts on one page
|
|
||||||
"label_name": "3475",
|
|
||||||
"location_type_id": "",
|
|
||||||
"_qf_Label_submit": "Make+Mailing+Labels"
|
|
||||||
}
|
|
||||||
res = self.download(data)
|
|
||||||
pdf_text = extract_text(io.BytesIO(res.content))
|
|
||||||
label_count = pdf_text.count(self.pdf_search_string)
|
|
||||||
if result_no == label_count:
|
|
||||||
self.passed(
|
|
||||||
"no missing mailing labels from pdf export. Expected: %d, Actual %d"
|
|
||||||
% (result_no, label_count)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self.failed(
|
|
||||||
"missing mailing labels from the pdf export. Expected: %d, Actual %d"
|
|
||||||
% (result_no, label_count)
|
|
||||||
)
|
|
||||||
|
|
||||||
def test(self):
|
|
||||||
try:
|
|
||||||
self.login()
|
|
||||||
self._test()
|
|
||||||
finally:
|
|
||||||
self.logout()
|
|
||||||
self.browser.close()
|
|
||||||
|
|
||||||
|
from civicrm_tester.activities_tab import ActivitiesTab
|
||||||
|
from civicrm_tester.contact_export import ContactExport
|
||||||
|
from civicrm_tester.steeringcommittee_print_labels import \
|
||||||
|
SteeringCommitteePrintLabels
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
parser = argparse.ArgumentParser(description="")
|
parser = argparse.ArgumentParser(description="")
|
||||||
@ -452,9 +32,9 @@ if __name__ == "__main__":
|
|||||||
cl_arg = (
|
cl_arg = (
|
||||||
arguments.user, arguments.passwd, arguments.dev, arguments.show_browser
|
arguments.user, arguments.passwd, arguments.dev, arguments.show_browser
|
||||||
)
|
)
|
||||||
SteeringCommitteePrintLabels(*cl_arg).test()
|
|
||||||
ActivitiesTab(*cl_arg).test_all_hardcoded_contacts()
|
ActivitiesTab(*cl_arg).test_all_hardcoded_contacts()
|
||||||
ContactExport(*cl_arg).test_hardcoded_search_terms()
|
ContactExport(*cl_arg).test_hardcoded_search_terms()
|
||||||
|
SteeringCommitteePrintLabels(*cl_arg).test()
|
||||||
|
|
||||||
# Mailing list
|
# Mailing list
|
||||||
# Load mailing list test and enter test data
|
# Load mailing list test and enter test data
|
||||||
|
Loading…
Reference in New Issue
Block a user