362 lines
14 KiB
Python
362 lines
14 KiB
Python
import argparse
|
|
import csv
|
|
import logging
|
|
import os
|
|
import re
|
|
import urllib.parse as urlparse
|
|
from urllib.parse import parse_qs
|
|
|
|
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"""
|
|
def __init__(self, user: str, passwd: str, dev: bool, show_browser: bool):
|
|
self.console = Console()
|
|
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 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 ContactExport(BaseTester):
|
|
"""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 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):
|
|
"""
|
|
Download export CSV 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"]}
|
|
)
|
|
qf_key = self.get_export_id()
|
|
# Most of this data is replicating the export settings so that the response is the csv data
|
|
data = {
|
|
"qfKey": qf_key,
|
|
"entryURL": self.base_url + "/civicrm/contact/search",
|
|
"_qf_Select_next": "Continue",
|
|
"exportOption": 1,
|
|
"mergeOption": 0,
|
|
"postal_greeting": 1,
|
|
"addressee": 1,
|
|
}
|
|
res = session.request(
|
|
"POST", self.base_url + "/civicrm/contact/search", data=data
|
|
)
|
|
return res
|
|
|
|
def calculate_exported_contacts_number(self):
|
|
"""
|
|
Downloads csv, saves it to tmp, opens it and checks if the number of contacts is equal to the reported number of contacts within the web ui
|
|
Has to be saved to disk due to the csv lib only wanting a file object, not str
|
|
"""
|
|
res = self.download_csv()
|
|
file_name = "/tmp/exportedRecords.csv"
|
|
with open(file_name, "w") as csv_file:
|
|
self.debug("writing csv to '%s'" % file_name)
|
|
csv_file.write(res.text)
|
|
with open(file_name, "r") as csv_file:
|
|
# 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
|
|
)
|
|
self.debug("deleting '%s'" % file_name)
|
|
os.remove(file_name)
|
|
return exported_number_exports
|
|
|
|
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 test(self, search_term: str):
|
|
try:
|
|
self.login()
|
|
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)
|
|
)
|
|
finally:
|
|
self.logout()
|
|
self.browser.close()
|
|
|
|
|
|
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. 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)
|
|
try:
|
|
self.login()
|
|
for cid in cid_tuple:
|
|
self.test(cid)
|
|
finally:
|
|
self.logout()
|
|
self.browser.close()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
parser = argparse.ArgumentParser(description="")
|
|
parser.add_argument(
|
|
"--user", "-u", type=str, dest="user", help="Username of account"
|
|
)
|
|
parser.add_argument(
|
|
"--pass", "-p", type=str, dest="passwd", help="Password of account"
|
|
)
|
|
parser.add_argument(
|
|
"--dev",
|
|
"-D",
|
|
dest="dev",
|
|
action="store_true",
|
|
help="Test dev site instead of production"
|
|
)
|
|
parser.add_argument(
|
|
"--show_browser",
|
|
"-s",
|
|
dest="show_browser",
|
|
action="store_true",
|
|
help="Show the web browser"
|
|
)
|
|
parser.set_defaults(dev=False, show_browser=False)
|
|
arguments = parser.parse_args()
|
|
ActivitiesTab(
|
|
arguments.user, arguments.passwd, arguments.dev, arguments.show_browser
|
|
).test_all_hardcoded_contacts()
|
|
ContactExport(
|
|
arguments.user, arguments.passwd, arguments.dev, arguments.show_browser
|
|
).test("John")
|
|
#ContactExport(arguments.user, arguments.passwd, arguments.dev).test("e")
|
|
|
|
# Mailing list
|
|
# Load mailing list test and enter test data
|
|
# send test email and check for soft crashes
|
|
# manual check the email sent
|
|
|
|
# Print labels
|
|
# Search contacts > by group "Steering Committee" > search
|
|
# Print labels for group, untick "do not mail" check box
|
|
# Resulting pdf might have missing names
|