diff --git a/app.py b/app.py index a6ce465..99b3e19 100644 --- a/app.py +++ b/app.py @@ -1,3 +1,3 @@ -from src.naturerec_web import create_app - -app = create_app() +from src.naturerec_web import create_app + +app = create_app() diff --git a/features/environment.py b/features/environment.py index 599bd54..615a0c4 100644 --- a/features/environment.py +++ b/features/environment.py @@ -1,127 +1,127 @@ -import os -import time -import platform -from sqlalchemy import text -from src.naturerec_model.model import create_database -from src.naturerec_model.logic import create_user -from behave import fixture, use_fixture -from selenium import webdriver -from selenium.webdriver.common.by import By -from selenium.common.exceptions import ElementNotInteractableException, NoSuchElementException -from flask_app_runner import FlaskAppRunner -from src.naturerec_web import create_app -from src.naturerec_model.model.database import Engine -from src.naturerec_model.model.utils import get_project_path - - -MAXIMUM_PAGE_LOAD_TIME = 5 - - -@fixture -def start_flask_server(context): - """ - Start the Nature Recorder web application on a background thread - - :param context: - """ - context.flask_runner = FlaskAppRunner("127.0.0.1", 5000, create_app("development")) - context.flask_runner.start() - yield context.flask_runner - - # As this behaves like a context manager, the following is called after the after_all() hook - context.flask_runner.stop_server() - context.flask_runner.join() - - -@fixture -def start_selenium_browser(context): - """ - Start a web browser to run the behave tests - - :param context: Behave context - """ - # Determine the OS and create an appropriate browser instance - os_name = platform.system() - if os_name == "Darwin": - context.browser = webdriver.Safari() - elif os_name == "Windows": - context.browser = webdriver.Edge() - else: - raise NotImplementedError() - - context.browser.implicitly_wait(MAXIMUM_PAGE_LOAD_TIME) - yield context.browser - - # As this behaves like a context manager, the following is called after the after_all() hook - context.browser.close() - - -@fixture -def create_test_database(_): - """ - Create and populate the test database - - :param _: Behave context (not used) - """ - create_database() - create_user("behave", "password") - - -@fixture -def login(context): - """ - Log in to the application - - :param context: Behave context - """ - # Browse to the login page and enter the username and password - url = context.flask_runner.make_url("auth/login") - context.browser.get(url) - context.browser.find_element(By.NAME, "username").send_keys("behave") - context.browser.find_element(By.NAME, "password").send_keys("password") - - # Click the "login" button - xpath = f"//*[text()='Login']" - elements = context.browser.find_elements(By.XPATH, xpath) - for element in elements: - try: - element.click() - except (ElementNotInteractableException, NoSuchElementException): - pass - - time.sleep(1) - - -def before_all(context): - """ - Set up the test environment before any scenarios are run - - :param context: Behave context - """ - use_fixture(create_test_database, context) - use_fixture(start_flask_server, context) - use_fixture(start_selenium_browser, context) - use_fixture(login, context) - - -def before_scenario(context, scenario): - """ - Initialise the database for every scenario - - :param context: Behave context (not used) - :param scenario: Behave scenario - """ - clear_down_script = os.path.join(get_project_path(), "features", "sql", "clear_database.sql") - with open(clear_down_script, mode="rt", encoding="utf-8") as f: - for statement in f.readlines(): - if statement: - Engine.execute(text(statement)) - - -def after_all(_): - """ - Tear down the test environment after all scenarios have run - - :param _: Behave context (not used) - """ - pass +import os +import time +import platform +from sqlalchemy import text +from src.naturerec_model.model import create_database +from src.naturerec_model.logic import create_user +from behave import fixture, use_fixture +from selenium import webdriver +from selenium.webdriver.common.by import By +from selenium.common.exceptions import ElementNotInteractableException, NoSuchElementException +from flask_app_runner import FlaskAppRunner +from src.naturerec_web import create_app +from src.naturerec_model.model.database import Engine +from src.naturerec_model.model.utils import get_project_path + + +MAXIMUM_PAGE_LOAD_TIME = 5 + + +@fixture +def start_flask_server(context): + """ + Start the Nature Recorder web application on a background thread + + :param context: + """ + context.flask_runner = FlaskAppRunner("127.0.0.1", 5000, create_app("development")) + context.flask_runner.start() + yield context.flask_runner + + # As this behaves like a context manager, the following is called after the after_all() hook + context.flask_runner.stop_server() + context.flask_runner.join() + + +@fixture +def start_selenium_browser(context): + """ + Start a web browser to run the behave tests + + :param context: Behave context + """ + # Determine the OS and create an appropriate browser instance + os_name = platform.system() + if os_name == "Darwin": + context.browser = webdriver.Safari() + elif os_name == "Windows": + context.browser = webdriver.Edge() + else: + raise NotImplementedError() + + context.browser.implicitly_wait(MAXIMUM_PAGE_LOAD_TIME) + yield context.browser + + # As this behaves like a context manager, the following is called after the after_all() hook + context.browser.close() + + +@fixture +def create_test_database(_): + """ + Create and populate the test database + + :param _: Behave context (not used) + """ + create_database() + create_user("behave", "password") + + +@fixture +def login(context): + """ + Log in to the application + + :param context: Behave context + """ + # Browse to the login page and enter the username and password + url = context.flask_runner.make_url("auth/login") + context.browser.get(url) + context.browser.find_element(By.NAME, "username").send_keys("behave") + context.browser.find_element(By.NAME, "password").send_keys("password") + + # Click the "login" button + xpath = f"//*[text()='Login']" + elements = context.browser.find_elements(By.XPATH, xpath) + for element in elements: + try: + element.click() + except (ElementNotInteractableException, NoSuchElementException): + pass + + time.sleep(1) + + +def before_all(context): + """ + Set up the test environment before any scenarios are run + + :param context: Behave context + """ + use_fixture(create_test_database, context) + use_fixture(start_flask_server, context) + use_fixture(start_selenium_browser, context) + use_fixture(login, context) + + +def before_scenario(context, scenario): + """ + Initialise the database for every scenario + + :param context: Behave context (not used) + :param scenario: Behave scenario + """ + clear_down_script = os.path.join(get_project_path(), "features", "sql", "clear_database.sql") + with open(clear_down_script, mode="rt", encoding="utf-8") as f: + for statement in f.readlines(): + if statement: + Engine.execute(text(statement)) + + +def after_all(_): + """ + Tear down the test environment after all scenarios have run + + :param _: Behave context (not used) + """ + pass diff --git a/features/sightings.feature b/features/sightings.feature index 003b5e3..911852e 100644 --- a/features/sightings.feature +++ b/features/sightings.feature @@ -1,49 +1,49 @@ -Feature: Sightings Management - Scenario: List today's sightings - Given A set of sightings - | Date | Location | Category | Species | Number | Gender | WithYoung | Notes | - | TODAY | Test Location | Birds | Blackbird | 1 | Male | No | Some notes | - | TODAY | Test Location | Mammals | Grey Squirrel | 1 | Unknown | No | More notes | - - When I navigate to the sightings page - Then There will be 2 sightings in the sightings list - - Scenario: List filtered sightings - Given A set of sightings - | Date | Location | Category | Species | Number | Gender | WithYoung | Notes | - | TODAY | Test Location | Birds | Blackbird | 1 | Male | No | Some notes | - | TODAY | Test Location | Mammals | Grey Squirrel | 1 | Unknown | No | More notes | - - When I navigate to the sightings page - And I fill in the sightings filter form - | Location | Category | Species | - | Test Location | Mammals | Grey Squirrel | - - And I click on the "Filter Sightings" button - Then There will be 1 sighting in the sightings list - - Scenario: List today's sightings when there are none - Given There are no "sightings" in the database - When I navigate to the sightings page - Then The sightings list will be empty - - Scenario: Create sighting - Given A set of locations - | Name | Address | City | County | Postcode | Country | Latitude | Longitude | - | Farmoor Reservoir | Cumnor Road | Farmoor | Oxfordshire | OX2 9NS | United Kingdom | 51.75800 | -1.34752 | - - And A set of categories - | Category | - | Birds | - - And A set of species - | Category | Species | - | Birds | Black-Headed Gull | - - When I navigate to the sightings entry page - And I fill in the sighting details - | Date | Location | Category | Species | Number | Gender | WithYoung | - | TODAY | Farmoor Reservoir | Birds | Black-Headed Gull | 1 | Unknown | No | - - And I click on the "Add Sighting" button - Then The sighting will be added to the database +Feature: Sightings Management + Scenario: List today's sightings + Given A set of sightings + | Date | Location | Category | Species | Number | Gender | WithYoung | Notes | + | TODAY | Test Location | Birds | Blackbird | 1 | Male | No | Some notes | + | TODAY | Test Location | Mammals | Grey Squirrel | 1 | Unknown | No | More notes | + + When I navigate to the sightings page + Then There will be 2 sightings in the sightings list + + Scenario: List filtered sightings + Given A set of sightings + | Date | Location | Category | Species | Number | Gender | WithYoung | Notes | + | TODAY | Test Location | Birds | Blackbird | 1 | Male | No | Some notes | + | TODAY | Test Location | Mammals | Grey Squirrel | 1 | Unknown | No | More notes | + + When I navigate to the sightings page + And I fill in the sightings filter form + | Location | Category | Species | + | Test Location | Mammals | Grey Squirrel | + + And I click on the "Filter Sightings" button + Then There will be 1 sighting in the sightings list + + Scenario: List today's sightings when there are none + Given There are no "sightings" in the database + When I navigate to the sightings page + Then The sightings list will be empty + + Scenario: Create sighting + Given A set of locations + | Name | Address | City | County | Postcode | Country | Latitude | Longitude | + | Farmoor Reservoir | Cumnor Road | Farmoor | Oxfordshire | OX2 9NS | United Kingdom | 51.75800 | -1.34752 | + + And A set of categories + | Category | + | Birds | + + And A set of species + | Category | Species | + | Birds | Black-Headed Gull | + + When I navigate to the sightings entry page + And I fill in the sighting details + | Date | Location | Category | Species | Number | Gender | WithYoung | + | TODAY | Farmoor Reservoir | Birds | Black-Headed Gull | 1 | Unknown | No | + + And I click on the "Add Sighting" button + Then The sighting will be added to the database diff --git a/features/species.feature b/features/species.feature index 7d3cce0..5800ea0 100644 --- a/features/species.feature +++ b/features/species.feature @@ -1,40 +1,40 @@ -Feature: Species management - - Scenario: List species when there are some species in the database - Given A set of species - | Category | Species | - | Birds | Red Kite | - | Amphibians | Frog | - - When I navigate to the species list page - And I select "Birds" as the "category" - And I click on the "List Species" button - Then There will be 1 species in the species list - - Scenario: List species when there are none in the database - Given A set of categories - | Category | - | Birds | - - And There are no "species" in the database - When I navigate to the species list page - And I select "Birds" as the "category" - And I click on the "List Species" button - Then The species list will be empty - - Scenario: Add a species - Given A set of categories - | Category | - | Birds | - - When I navigate to the species list page - And I click on the "Add Species" button - And I fill in the Species details - | Category | Species | - | Birds | Sparrowhawk | - - And I click on the "Add Species" button - And I navigate to the species list page - And I select "Birds" as the "category" - And I click on the "List Species" button - Then There will be 1 species in the species list +Feature: Species management + + Scenario: List species when there are some species in the database + Given A set of species + | Category | Species | + | Birds | Red Kite | + | Amphibians | Frog | + + When I navigate to the species list page + And I select "Birds" as the "category" + And I click on the "List Species" button + Then There will be 1 species in the species list + + Scenario: List species when there are none in the database + Given A set of categories + | Category | + | Birds | + + And There are no "species" in the database + When I navigate to the species list page + And I select "Birds" as the "category" + And I click on the "List Species" button + Then The species list will be empty + + Scenario: Add a species + Given A set of categories + | Category | + | Birds | + + When I navigate to the species list page + And I click on the "Add Species" button + And I fill in the Species details + | Category | Species | + | Birds | Sparrowhawk | + + And I click on the "Add Species" button + And I navigate to the species list page + And I select "Birds" as the "category" + And I click on the "List Species" button + Then There will be 1 species in the species list diff --git a/features/steps/common.py b/features/steps/common.py index 4b136e9..a0c7732 100644 --- a/features/steps/common.py +++ b/features/steps/common.py @@ -1,169 +1,169 @@ -import time -from behave import given, when, then -from selenium.common.exceptions import ElementNotInteractableException, NoSuchElementException -from selenium.webdriver.common.by import By -from src.naturerec_model.model import Gender -from src.naturerec_model.logic import create_sighting -from helpers import get_date_from_string, select_option -from helpers import create_test_location, create_test_category, create_test_species, create_test_scheme - - -@given("A set of locations") -def _(context): - """ - Create one or more locations presented in a data table in the following form: - - | Name | Address | City | County | Postcode | Country | Latitude | Longitude | - | Farmoor Reservoir | Cumnor Road | Farmoor | Oxfordshire | OX2 9NS | United Kingdom | 51.75800 | -1.34752 | - - :param context: Behave context - """ - for row in context.table: - _ = create_test_location(row["Name"]) - - -@given("A set of categories") -def _(context): - """ - Create one or more categories presented in a data table in the following form: - - | Category | - | Birds | - - :param context: Behave context - """ - for row in context.table: - _ = create_test_category(row["Category"]) - - -@given("A set of species") -def _(context): - """ - Create one or more species presented in a data table in the following form: - - | Category | Species | - | Birds | Red Kite | - | Amphibians | Frog | - - :param context: Behave context - """ - for row in context.table: - category = create_test_category(row["Category"]) - _ = create_test_species(row["Species"], category.id) - - -@given("A set of sightings") -def _(context): - """ - Create one or more sightings presented in a data table in the following form: - - | Date | Location | Category | Species | Number | Gender | WithYoung | Notes | - | 01/01/2022 | Test Location | Birds | Blackbird | 1 | Male | No | Some notes | - - :param context: Behave context - """ - for row in context.table: - sighting_date = get_date_from_string(row["Date"]) - location = create_test_location(row["Location"]) - category = create_test_category(row["Category"]) - species = create_test_species(row["Species"], category.id) - gender = [key for key, value in Gender.gender_map().items() if value == row["Gender"]][0] - with_young = 1 if row["WithYoung"] == "Yes" else 0 - notes = row["Notes"] - _ = create_sighting(location.id, species.id, sighting_date, int(row["Number"]), gender, with_young, notes) - - -@given("A set of conservation status schemes") -def _(context): - """ - Create one or more conservation status schemes presented in a data table in the following form: - - | Scheme | - | BOCC5 | - - :param context: Behave context - """ - for row in context.table: - _ = create_test_scheme(row["Scheme"]) - - -@given("There are no \"{item_type}\" in the database") -def _(_, item_type): - """ - Step that takes no action when there are no locations, categories etc. in the database. The - before_scenario() method takes care of this, so no action is required. This step definition - is provided solely to make the scenarios make sense - - :param _: Behave context (ignore) - :param item_type: Item type (not used) - """ - pass - - -@when("I select \"{selection}\" as the \"{selector}\"") -def _(context, selection, selector): - """ - Select list selector - - :param context: Behave context - :param selection: Visible text for the selection - :param selector: Name of the select list element - """ - select_option(context, selector, selection, None) - - -@when("I click on the \"{button_text}\" button") -def _(context, button_text): - """ - Button clicker based on the button text - - :param context: Behave context - :param button_text: Button text - """ - time.sleep(1) - xpath = f"//*[text()='{button_text}']" - elements = context.browser.find_elements(By.XPATH, xpath) - for element in elements: - try: - element.click() - except (ElementNotInteractableException, NoSuchElementException): - pass - - -@when("I click on the \"{icon_type}\" icon") -def _(context, icon_type): - """ - Icon clicker based on the icon type text - - :param context: Behave context - :param icon_type: Type of icon to click - """ - class_name = f"fa-{icon_type}" - elements = context.browser.find_elements(By.CLASS_NAME, class_name) - for element in elements: - try: - element.click() - except (ElementNotInteractableException, NoSuchElementException): - pass - - -@then("I am taken to the \"{title}\" page") -def _(context, title): - """ - Wait for a page to serve as the result of a previous step executing then confirm it's title - - :param context: Behave context - :param title: Expected page title text - """ - time.sleep(1) - assert title in context.browser.title - - -@then("There will be {number} {item_type} in the export file") -@then("There will be {number} {item_type} in the export file") -def _(context, number, item_type): - time.sleep(2) - with open(context.export_filepath, mode="rt", encoding="utf-8") as f: - lines = f.readlines() - # Number of lines plus 1 to account for the headers - assert len(lines) == int(number) + 1 +import time +from behave import given, when, then +from selenium.common.exceptions import ElementNotInteractableException, NoSuchElementException +from selenium.webdriver.common.by import By +from src.naturerec_model.model import Gender +from src.naturerec_model.logic import create_sighting +from helpers import get_date_from_string, select_option +from helpers import create_test_location, create_test_category, create_test_species, create_test_scheme + + +@given("A set of locations") +def _(context): + """ + Create one or more locations presented in a data table in the following form: + + | Name | Address | City | County | Postcode | Country | Latitude | Longitude | + | Farmoor Reservoir | Cumnor Road | Farmoor | Oxfordshire | OX2 9NS | United Kingdom | 51.75800 | -1.34752 | + + :param context: Behave context + """ + for row in context.table: + _ = create_test_location(row["Name"]) + + +@given("A set of categories") +def _(context): + """ + Create one or more categories presented in a data table in the following form: + + | Category | + | Birds | + + :param context: Behave context + """ + for row in context.table: + _ = create_test_category(row["Category"]) + + +@given("A set of species") +def _(context): + """ + Create one or more species presented in a data table in the following form: + + | Category | Species | + | Birds | Red Kite | + | Amphibians | Frog | + + :param context: Behave context + """ + for row in context.table: + category = create_test_category(row["Category"]) + _ = create_test_species(row["Species"], category.id) + + +@given("A set of sightings") +def _(context): + """ + Create one or more sightings presented in a data table in the following form: + + | Date | Location | Category | Species | Number | Gender | WithYoung | Notes | + | 01/01/2022 | Test Location | Birds | Blackbird | 1 | Male | No | Some notes | + + :param context: Behave context + """ + for row in context.table: + sighting_date = get_date_from_string(row["Date"]) + location = create_test_location(row["Location"]) + category = create_test_category(row["Category"]) + species = create_test_species(row["Species"], category.id) + gender = [key for key, value in Gender.gender_map().items() if value == row["Gender"]][0] + with_young = 1 if row["WithYoung"] == "Yes" else 0 + notes = row["Notes"] + _ = create_sighting(location.id, species.id, sighting_date, int(row["Number"]), gender, with_young, notes) + + +@given("A set of conservation status schemes") +def _(context): + """ + Create one or more conservation status schemes presented in a data table in the following form: + + | Scheme | + | BOCC5 | + + :param context: Behave context + """ + for row in context.table: + _ = create_test_scheme(row["Scheme"]) + + +@given("There are no \"{item_type}\" in the database") +def _(_, item_type): + """ + Step that takes no action when there are no locations, categories etc. in the database. The + before_scenario() method takes care of this, so no action is required. This step definition + is provided solely to make the scenarios make sense + + :param _: Behave context (ignore) + :param item_type: Item type (not used) + """ + pass + + +@when("I select \"{selection}\" as the \"{selector}\"") +def _(context, selection, selector): + """ + Select list selector + + :param context: Behave context + :param selection: Visible text for the selection + :param selector: Name of the select list element + """ + select_option(context, selector, selection, None) + + +@when("I click on the \"{button_text}\" button") +def _(context, button_text): + """ + Button clicker based on the button text + + :param context: Behave context + :param button_text: Button text + """ + time.sleep(1) + xpath = f"//*[text()='{button_text}']" + elements = context.browser.find_elements(By.XPATH, xpath) + for element in elements: + try: + element.click() + except (ElementNotInteractableException, NoSuchElementException): + pass + + +@when("I click on the \"{icon_type}\" icon") +def _(context, icon_type): + """ + Icon clicker based on the icon type text + + :param context: Behave context + :param icon_type: Type of icon to click + """ + class_name = f"fa-{icon_type}" + elements = context.browser.find_elements(By.CLASS_NAME, class_name) + for element in elements: + try: + element.click() + except (ElementNotInteractableException, NoSuchElementException): + pass + + +@then("I am taken to the \"{title}\" page") +def _(context, title): + """ + Wait for a page to serve as the result of a previous step executing then confirm it's title + + :param context: Behave context + :param title: Expected page title text + """ + time.sleep(1) + assert title in context.browser.title + + +@then("There will be {number} {item_type} in the export file") +@then("There will be {number} {item_type} in the export file") +def _(context, number, item_type): + time.sleep(2) + with open(context.export_filepath, mode="rt", encoding="utf-8") as f: + lines = f.readlines() + # Number of lines plus 1 to account for the headers + assert len(lines) == int(number) + 1 diff --git a/features/steps/helpers.py b/features/steps/helpers.py index fd6c3ff..abda42d 100644 --- a/features/steps/helpers.py +++ b/features/steps/helpers.py @@ -1,165 +1,165 @@ -import time -import datetime -import os -from selenium.webdriver.common.by import By -from selenium.webdriver.support.select import Select -from src.naturerec_model.model import Sighting -from src.naturerec_model.model.utils import get_data_path -from src.naturerec_model.logic import get_location, create_location -from src.naturerec_model.logic import get_category, create_category -from src.naturerec_model.logic import get_species, create_species -from src.naturerec_model.logic import get_status_scheme, create_status_scheme - - -def get_date_from_string(date_string): - """ - Given a date string, return the corresponding date - - :param date_string: Representation of a date as DD/MM/YYYY - :return: The date object corresponding the specified date - """ - if date_string.casefold() == "TODAY".casefold(): - return datetime.datetime.today().date() - else: - return datetime.datetime.strptime(date_string, Sighting.DATE_IMPORT_FORMAT).date() - - -def create_test_location(name): - """ - Create a named location, if it doesn't already exist - - :param name: Location name - :return: Instance of the Location() class - """ - try: - location = get_location(name) - except ValueError: - location = create_location(name, "Oxfordshire", "United Kingdom") - return location - - -def create_test_category(name): - """ - Create a named category, if it doesn't already exist - - :param name: Category name - :return: Instance of the Category() class - """ - try: - category = get_category(name) - except ValueError: - category = create_category(name) - return category - - -def create_test_species(name, category_id): - """ - Create a named species, if it doesn't already exist - - :param name: Species name - :param category_id: Category the species belongs to - :return: Instance of the Species() class - """ - try: - species = get_species(name) - except ValueError: - species = create_species(category_id, name) - return species - - -def create_test_scheme(name): - """ - Create a named conservation status scheme, if it doesn't already exist - - :param name: Scheme name - :return: Instance of the StatusScheme() class - """ - try: - scheme = get_status_scheme(name) - except ValueError: - scheme = create_status_scheme(name) - return scheme - - -def select_option(context, element, text, delay): - """ - Select an option in a select list based on the visible text - - :param context: Behave context - :param element: Name of the HTML select element - :param text: Visible text for the option to select - :param delay: Time, in seconds, to wait before making the selection or 0/None for no delay - """ - # If requested, wait for the specified delay, to allow the select list to be rendered - if delay: - time.sleep(delay) - - # Click on the select element, first, to make its options visible and ready to interact with - select_element = context.browser.find_element(By.NAME, element) - select_element.click() - - # Create a select object from the element and select the requested value - selector = Select(select_element) - selector.select_by_visible_text(text) - - -def confirm_table_row_count(context, expected, delay): - """ - Find a results table in the current page, count the number of rows in the table body - and confirm they match the expected count - - :param context: Behave context, which contains a member for the Selenium browser driver - :param expected: The expected row count - :param delay: THe number of seconds to wait before attempting the check (or 0/None for no delay) - """ - # If requested, wait for the specified delay, to allow the table to be rendered - if delay: - time.sleep(delay) - - # Find the table on the page - table = context.browser.find_element(By.CLASS_NAME, "striped") - table_body = table.find_element(By.XPATH, ".//tbody") - table_rows = table_body.find_elements(By.XPATH, ".//tr") - - # Confirm - expected = int(expected) - actual = len(table_rows) - assert actual == expected - - -def confirm_span_exists(context, text, delay): - """ - Confirm that a span containing the specified text exists on the page - - :param context: Behave context, which contains a member for the Selenium browser driver - :param text: The expected text in the span - :param delay: THe number of seconds to wait before attempting the check (or 0/None for no delay) - """ - # If requested, wait for the specified delay, to allow the span to be rendered - if delay: - time.sleep(delay) - - # Find the span with the specified text - xpath = f"//span[text()='{text}']" - _ = context.browser.find_element(By.XPATH, xpath) - - -def get_export_filepath(filename): - """ - Given a filename for sightings export, return the full path to the exported CSV file - - :param filename: Export file name - :return: Full path to the export file with that name - """ - return os.path.join(get_data_path(), "exports", filename) - - -def delete_export_file(filename): - """ - Delete the export file with the specified name - - :param filename: Export file name - """ - filepath = get_export_filepath(filename) - if os.path.exists(filepath): - os.unlink(filepath) +import time +import datetime +import os +from selenium.webdriver.common.by import By +from selenium.webdriver.support.select import Select +from src.naturerec_model.model import Sighting +from src.naturerec_model.model.utils import get_data_path +from src.naturerec_model.logic import get_location, create_location +from src.naturerec_model.logic import get_category, create_category +from src.naturerec_model.logic import get_species, create_species +from src.naturerec_model.logic import get_status_scheme, create_status_scheme + + +def get_date_from_string(date_string): + """ + Given a date string, return the corresponding date + + :param date_string: Representation of a date as DD/MM/YYYY + :return: The date object corresponding the specified date + """ + if date_string.casefold() == "TODAY".casefold(): + return datetime.datetime.today().date() + else: + return datetime.datetime.strptime(date_string, Sighting.DATE_IMPORT_FORMAT).date() + + +def create_test_location(name): + """ + Create a named location, if it doesn't already exist + + :param name: Location name + :return: Instance of the Location() class + """ + try: + location = get_location(name) + except ValueError: + location = create_location(name, "Oxfordshire", "United Kingdom") + return location + + +def create_test_category(name): + """ + Create a named category, if it doesn't already exist + + :param name: Category name + :return: Instance of the Category() class + """ + try: + category = get_category(name) + except ValueError: + category = create_category(name) + return category + + +def create_test_species(name, category_id): + """ + Create a named species, if it doesn't already exist + + :param name: Species name + :param category_id: Category the species belongs to + :return: Instance of the Species() class + """ + try: + species = get_species(name) + except ValueError: + species = create_species(category_id, name) + return species + + +def create_test_scheme(name): + """ + Create a named conservation status scheme, if it doesn't already exist + + :param name: Scheme name + :return: Instance of the StatusScheme() class + """ + try: + scheme = get_status_scheme(name) + except ValueError: + scheme = create_status_scheme(name) + return scheme + + +def select_option(context, element, text, delay): + """ + Select an option in a select list based on the visible text + + :param context: Behave context + :param element: Name of the HTML select element + :param text: Visible text for the option to select + :param delay: Time, in seconds, to wait before making the selection or 0/None for no delay + """ + # If requested, wait for the specified delay, to allow the select list to be rendered + if delay: + time.sleep(delay) + + # Click on the select element, first, to make its options visible and ready to interact with + select_element = context.browser.find_element(By.NAME, element) + select_element.click() + + # Create a select object from the element and select the requested value + selector = Select(select_element) + selector.select_by_visible_text(text) + + +def confirm_table_row_count(context, expected, delay): + """ + Find a results table in the current page, count the number of rows in the table body + and confirm they match the expected count + + :param context: Behave context, which contains a member for the Selenium browser driver + :param expected: The expected row count + :param delay: THe number of seconds to wait before attempting the check (or 0/None for no delay) + """ + # If requested, wait for the specified delay, to allow the table to be rendered + if delay: + time.sleep(delay) + + # Find the table on the page + table = context.browser.find_element(By.CLASS_NAME, "striped") + table_body = table.find_element(By.XPATH, ".//tbody") + table_rows = table_body.find_elements(By.XPATH, ".//tr") + + # Confirm + expected = int(expected) + actual = len(table_rows) + assert actual == expected + + +def confirm_span_exists(context, text, delay): + """ + Confirm that a span containing the specified text exists on the page + + :param context: Behave context, which contains a member for the Selenium browser driver + :param text: The expected text in the span + :param delay: THe number of seconds to wait before attempting the check (or 0/None for no delay) + """ + # If requested, wait for the specified delay, to allow the span to be rendered + if delay: + time.sleep(delay) + + # Find the span with the specified text + xpath = f"//span[text()='{text}']" + _ = context.browser.find_element(By.XPATH, xpath) + + +def get_export_filepath(filename): + """ + Given a filename for sightings export, return the full path to the exported CSV file + + :param filename: Export file name + :return: Full path to the export file with that name + """ + return os.path.join(get_data_path(), "exports", filename) + + +def delete_export_file(filename): + """ + Delete the export file with the specified name + + :param filename: Export file name + """ + filepath = get_export_filepath(filename) + if os.path.exists(filepath): + os.unlink(filepath) diff --git a/features/steps/jobs.py b/features/steps/jobs.py index b425bb7..43afdd2 100644 --- a/features/steps/jobs.py +++ b/features/steps/jobs.py @@ -1,42 +1,42 @@ -import datetime -from behave import given, when, then -from helpers import confirm_table_row_count, create_test_location, create_test_category, create_test_species -from src.naturerec_model.model import Gender, Session, JobStatus -from src.naturerec_model.logic import create_sighting -from src.naturerec_model.data_exchange import SightingsExportHelper - -@given("The jobs list is empty") -def _(context): - with Session.begin() as session: - jobs = session.query(JobStatus).all() - for job in jobs: - session.delete(job) - - -@given("I have started a sightings export") -def _(context): - # Create a sighting to export - sighting_date = datetime.datetime.today().date() - location = create_test_location("Farmoor Reservoir") - category = create_test_category("Birds") - species = create_test_species("Cormorant", category.id) - gender = [key for key, value in Gender.gender_map().items() if value == "Unknown"][0] - _ = create_sighting(location.id, species.id, sighting_date, None, gender, 0, None) - - # Kick off the export - exporter = SightingsExportHelper("sightings.csv", None, None, None, None) - exporter.start() - exporter.join() - - -@when("I navigate to the job list page") -def _(context): - url = context.flask_runner.make_url("jobs/list") - context.browser.get(url) - assert "Background Job Status" in context.browser.title - - -@then("There will be {number} jobs in the jobs list") -@then("There will be {number} job in the jobs list") -def _(context, number): - confirm_table_row_count(context, number, 1) +import datetime +from behave import given, when, then +from helpers import confirm_table_row_count, create_test_location, create_test_category, create_test_species +from src.naturerec_model.model import Gender, Session, JobStatus +from src.naturerec_model.logic import create_sighting +from src.naturerec_model.data_exchange import SightingsExportHelper + +@given("The jobs list is empty") +def _(context): + with Session.begin() as session: + jobs = session.query(JobStatus).all() + for job in jobs: + session.delete(job) + + +@given("I have started a sightings export") +def _(context): + # Create a sighting to export + sighting_date = datetime.datetime.today().date() + location = create_test_location("Farmoor Reservoir") + category = create_test_category("Birds") + species = create_test_species("Cormorant", category.id) + gender = [key for key, value in Gender.gender_map().items() if value == "Unknown"][0] + _ = create_sighting(location.id, species.id, sighting_date, None, gender, 0, None) + + # Kick off the export + exporter = SightingsExportHelper("sightings.csv", None, None, None, None) + exporter.start() + exporter.join() + + +@when("I navigate to the job list page") +def _(context): + url = context.flask_runner.make_url("jobs/list") + context.browser.get(url) + assert "Background Job Status" in context.browser.title + + +@then("There will be {number} jobs in the jobs list") +@then("There will be {number} job in the jobs list") +def _(context, number): + confirm_table_row_count(context, number, 1) diff --git a/features/steps/reports.py b/features/steps/reports.py index c39020c..c0b51b3 100644 --- a/features/steps/reports.py +++ b/features/steps/reports.py @@ -1,50 +1,50 @@ -from behave import when, then -from selenium.webdriver.common.by import By -from selenium.webdriver.common.keys import Keys -from features.steps.helpers import select_option, get_date_from_string, confirm_table_row_count -from src.naturerec_model.model import Sighting - - -@when("I navigate to the location report page") -def _(context): - url = context.flask_runner.make_url("reports/location") - context.browser.get(url) - assert "Location Report" in context.browser.title - - -@when("I navigate to the species report page") -def _(context): - url = context.flask_runner.make_url("reports/species") - context.browser.get(url) - assert "Species by Date Report" in context.browser.title - - -@when("I fill in the location report details") -def _(context): - row = context.table.rows[0] - select_option(context, "location", row["Location"], None) - select_option(context, "category", row["Category"], None) - from_date = get_date_from_string(row["From"]).strftime(Sighting.DATE_DISPLAY_FORMAT) - context.browser.find_element(By.NAME, "from_date").send_keys(from_date) - # With the date-picker in place, use ESC to close it then ENTER to submit the form - context.browser.find_element(By.NAME, "from_date").send_keys(Keys.ESCAPE) - context.browser.find_element(By.NAME, "from_date").send_keys(Keys.ENTER) - - -@when("I fill in the species report details") -def _(context): - row = context.table.rows[0] - select_option(context, "location", row["Location"], None) - select_option(context, "category", row["Category"], None) - select_option(context, "species", row["Species"], 1) - from_date = get_date_from_string(row["From"]).strftime(Sighting.DATE_DISPLAY_FORMAT) - context.browser.find_element(By.NAME, "from_date").send_keys(from_date) - # With the date-picker in place, use ESC to close it then ENTER to submit the form - context.browser.find_element(By.NAME, "from_date").send_keys(Keys.ESCAPE) - context.browser.find_element(By.NAME, "from_date").send_keys(Keys.ENTER) - - -@then("There will be {number} results in the report table") -@then("There will be {number} result in the report table") -def _(context, number): - confirm_table_row_count(context, number, 5) +from behave import when, then +from selenium.webdriver.common.by import By +from selenium.webdriver.common.keys import Keys +from features.steps.helpers import select_option, get_date_from_string, confirm_table_row_count +from src.naturerec_model.model import Sighting + + +@when("I navigate to the location report page") +def _(context): + url = context.flask_runner.make_url("reports/location") + context.browser.get(url) + assert "Location Report" in context.browser.title + + +@when("I navigate to the species report page") +def _(context): + url = context.flask_runner.make_url("reports/species") + context.browser.get(url) + assert "Species by Date Report" in context.browser.title + + +@when("I fill in the location report details") +def _(context): + row = context.table.rows[0] + select_option(context, "location", row["Location"], None) + select_option(context, "category", row["Category"], None) + from_date = get_date_from_string(row["From"]).strftime(Sighting.DATE_DISPLAY_FORMAT) + context.browser.find_element(By.NAME, "from_date").send_keys(from_date) + # With the date-picker in place, use ESC to close it then ENTER to submit the form + context.browser.find_element(By.NAME, "from_date").send_keys(Keys.ESCAPE) + context.browser.find_element(By.NAME, "from_date").send_keys(Keys.ENTER) + + +@when("I fill in the species report details") +def _(context): + row = context.table.rows[0] + select_option(context, "location", row["Location"], None) + select_option(context, "category", row["Category"], None) + select_option(context, "species", row["Species"], 1) + from_date = get_date_from_string(row["From"]).strftime(Sighting.DATE_DISPLAY_FORMAT) + context.browser.find_element(By.NAME, "from_date").send_keys(from_date) + # With the date-picker in place, use ESC to close it then ENTER to submit the form + context.browser.find_element(By.NAME, "from_date").send_keys(Keys.ESCAPE) + context.browser.find_element(By.NAME, "from_date").send_keys(Keys.ENTER) + + +@then("There will be {number} results in the report table") +@then("There will be {number} result in the report table") +def _(context, number): + confirm_table_row_count(context, number, 5) diff --git a/features/steps/sightings.py b/features/steps/sightings.py index 25f2c04..4a85417 100644 --- a/features/steps/sightings.py +++ b/features/steps/sightings.py @@ -1,66 +1,66 @@ -import datetime -from behave import when, then -from selenium.webdriver.common.by import By -from helpers import confirm_table_row_count, confirm_span_exists, select_option -from src.naturerec_model.model import Sighting - - -@when("I navigate to the sightings page") -def _(context): - url = context.flask_runner.make_url("sightings/list") - context.browser.get(url) - assert "Sightings" in context.browser.title - - -@when("I fill in the sightings filter form") -def _(context): - row = context.table.rows[0] - select_option(context, "location", row["Location"], None) - select_option(context, "category", row["Category"], None) - select_option(context, "species", row["Species"], 1) - - -@when("I navigate to the sightings entry page") -def _(context): - url = context.flask_runner.make_url("/sightings/edit") - context.browser.get(url) - assert "Add Sighting" in context.browser.title - - -@when("I fill in the sighting details") -def _(context): - # Capture the sighting details for use in the confirmation step - row = context.table.rows[0] - context.sighting_species = row["Species"] - context.sighting_location = row["Location"] - context.sighting_date = datetime.datetime.today().date().strftime(Sighting.DATE_DISPLAY_FORMAT) - - # Select the values - category = row["Category"] - select_option(context, "location", row["Location"], None) - select_option(context, "category", category, None) - select_option(context, "species", row["Species"], 1) - - # Some controls are only displayed for certain categories - if category.casefold() in ["birds", "mammals"]: - context.browser.find_element(By.NAME, "number").send_keys(row["Number"]) - select_option(context, "gender", row["Gender"], 1) - context.browser.find_element(By.NAME, "with_young").send_keys("") - select_option(context, "with_young", row["WithYoung"], 1) - - -@then("There will be {number} sightings in the sightings list") -@then("There will be {number} sighting in the sightings list") -def _(context, number): - confirm_table_row_count(context, number, 1) - - -@then("The sightings list will be empty") -def _(context): - confirm_span_exists(context, "There are no sightings in the database matching the specified criteria", 1) - - -@then("The sighting will be added to the database") -def _(context): - text = f"Added sighting of {context.sighting_species} at {context.sighting_location} on {context.sighting_date}" - confirm_span_exists(context, text, 1) +import datetime +from behave import when, then +from selenium.webdriver.common.by import By +from helpers import confirm_table_row_count, confirm_span_exists, select_option +from src.naturerec_model.model import Sighting + + +@when("I navigate to the sightings page") +def _(context): + url = context.flask_runner.make_url("sightings/list") + context.browser.get(url) + assert "Sightings" in context.browser.title + + +@when("I fill in the sightings filter form") +def _(context): + row = context.table.rows[0] + select_option(context, "location", row["Location"], None) + select_option(context, "category", row["Category"], None) + select_option(context, "species", row["Species"], 1) + + +@when("I navigate to the sightings entry page") +def _(context): + url = context.flask_runner.make_url("/sightings/edit") + context.browser.get(url) + assert "Add Sighting" in context.browser.title + + +@when("I fill in the sighting details") +def _(context): + # Capture the sighting details for use in the confirmation step + row = context.table.rows[0] + context.sighting_species = row["Species"] + context.sighting_location = row["Location"] + context.sighting_date = datetime.datetime.today().date().strftime(Sighting.DATE_DISPLAY_FORMAT) + + # Select the values + category = row["Category"] + select_option(context, "location", row["Location"], None) + select_option(context, "category", category, None) + select_option(context, "species", row["Species"], 1) + + # Some controls are only displayed for certain categories + if category.casefold() in ["birds", "mammals"]: + context.browser.find_element(By.NAME, "number").send_keys(row["Number"]) + select_option(context, "gender", row["Gender"], 1) + context.browser.find_element(By.NAME, "with_young").send_keys("") + select_option(context, "with_young", row["WithYoung"], 1) + + +@then("There will be {number} sightings in the sightings list") +@then("There will be {number} sighting in the sightings list") +def _(context, number): + confirm_table_row_count(context, number, 1) + + +@then("The sightings list will be empty") +def _(context): + confirm_span_exists(context, "There are no sightings in the database matching the specified criteria", 1) + + +@then("The sighting will be added to the database") +def _(context): + text = f"Added sighting of {context.sighting_species} at {context.sighting_location} on {context.sighting_date}" + confirm_span_exists(context, text, 1) diff --git a/run_gunicorn.sh b/run_gunicorn.sh index 303ee74..0a7cbe8 100644 --- a/run_gunicorn.sh +++ b/run_gunicorn.sh @@ -1,5 +1,5 @@ -#!/bin/sh -f - -export PROJECT_ROOT=$( cd "$(dirname "$0")" ; pwd -P ) -export PYTHONPATH="$PROJECT_ROOT/src:$PYTHONPATH" -gunicorn --bind=0.0.0.0 --timeout 600 app:app +#!/bin/sh -f + +export PROJECT_ROOT=$( cd "$(dirname "$0")" ; pwd -P ) +export PYTHONPATH="$PROJECT_ROOT/src:$PYTHONPATH" +gunicorn --bind=0.0.0.0 --timeout 600 app:app diff --git a/src/naturerec_web/__init__.py b/src/naturerec_web/__init__.py index 49ed0b4..ed7cac0 100644 --- a/src/naturerec_web/__init__.py +++ b/src/naturerec_web/__init__.py @@ -32,6 +32,11 @@ def create_app(environment="production"): config_object = f"naturerec_web.config.{'ProductionConfig' if environment == 'production' else 'DevelopmentConfig'}" app.config.from_object(config_object) + app.config.update( + SESSION_COOKIE_SAMESITE="Strict", + SESSION_COOKIE_HTTPONLY=True, + PERMANENT_SESSION_LIFETIME=600 + ) # Register the blueprints app.secret_key = os.environ["SECRET_KEY"] @@ -66,5 +71,20 @@ def load_user(user_id): """ return get_user(int(user_id)) + @app.after_request + def add_security_headers(response): + """ + Enforce security-related response headers + + :param response: Response object + :return: Response object with headers set + """ + response.headers["Content-Security-Policy"] = "default-src 'self'; frame-ancestors 'none'; form-action 'self'" + response.headers["X-Frame-Options"] = "DENY" + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-XSS-Protection"] = "1; mode=block" + return response + return app + diff --git a/tests/locust_tests/locustfile.py b/tests/locust_tests/locustfile.py index 9c33a6c..12d25d6 100644 --- a/tests/locust_tests/locustfile.py +++ b/tests/locust_tests/locustfile.py @@ -1,306 +1,306 @@ -import os -import time -import datetime -from bs4 import BeautifulSoup -from random import randrange -from locust import HttpUser, task, between, events -from locust_tests.flask_app_runner import FlaskAppRunner -from src.naturerec_model.model import create_database, get_data_path, Sighting -from src.naturerec_model.logic import list_locations, list_categories, list_species, create_user -from src.naturerec_model.data_exchange import SightingsImportHelper, StatusImportHelper -from src.naturerec_web import create_app - -TEST_USER_NAME = "locust" -TEST_PASSWORD = "password" - - -flask_runner = FlaskAppRunner("127.0.0.1", 5000, create_app()) - - -@events.test_start.add_listener -def on_test_start(environment, **kwargs): - """ - Before any tests run, start the Flask application - - :param environment: Ignored - :param kwargs: Ignored - """ - # Reset the database - create_database() - - # Create a login - create_user(TEST_USER_NAME, TEST_PASSWORD) - - # Import some sample sightings - sightings_file = os.path.join(get_data_path(), "imports", "locust_sightings.csv") - with open(sightings_file, mode="rt", encoding="utf-8") as f: - importer = SightingsImportHelper(f) - importer.start() - importer.join() - - # Import a sample conservation status scheme - scheme_file = os.path.join(get_data_path(), "imports", "locust_bocc5.csv") - with open(scheme_file, mode="rt", encoding="utf-8") as f: - importer = StatusImportHelper(f) - importer.start() - importer.join() - - # Start the site - global flask_runner - flask_runner.start() - - -@events.test_stop.add_listener -def on_test_stop(environment, **kwargs): - """ - When the tests complete, stop the Flask application - - :param environment: Ignored - :param kwargs: Ignored - """ - global flask_runner - flask_runner.stop_server() - flask_runner.join() - - -class NatureRecorderUser(HttpUser): - """ - Locust load test, targeting the Nature Recorder application hosted locally in the flask development server. The - tests are weighted as follows: - - +----------------------------------+----+ - | Test | % | - +----------------------------------+----+ - | go_to_home_page | 1 | - +----------------------------------+----+ - | list_locations | 1 | - +----------------------------------+----+ - | add_location | 1 | - +----------------------------------+----+ - | list_categories | 1 | - +----------------------------------+----+ - | add_category | 1 | - +----------------------------------+----+ - | list_species | 1 | - +----------------------------------+----+ - | add_species | 1 | - +----------------------------------+----+ - | list_sightings | 20 | - +----------------------------------+----+ - | add_sighting | 70 | - +----------------------------------+----+ - | list_conservation_status_schemes | 1 | - +----------------------------------+----+ - | show_life_list | 1 | - +----------------------------------+----+ - | list_recent_background_jobs | 1 | - +----------------------------------+----+ - """ - - #: Simulated users will wait between 1 and 5 seconds per task - wait_time = between(1, 5) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self._locations = None - self._categories = None - - def on_start(self): - """ - Establish some facts about the data so the tests can simulate realistic actions - """ - self._locations = list_locations() - self._categories = list_categories() - self._login() - - @task - def go_to_home_page(self): - """ - Task to open the home page of the application - """ - self.client.get("/") - - @task - def list_locations(self): - """ - Task to simulate listing the locations - """ - self.client.get("/locations/list") - - @task - def add_location(self): - """ - Task to simulate adding a location - """ - csrf_token = self._get_csrf_token_for_form("/locations/edit") - name = self._get_name("Location") - county = self._get_name("County") - country = self._get_name("Country") - self.client.post("/locations/edit", data={ - "name": name, - "address": "", - "city": "", - "county": county, - "postcode": "", - "country": country, - "latitude": "", - "longitude": "", - "csrf_token": csrf_token - }) - - @task - def list_categories(self): - """ - Task to simulate listing the categories - """ - self.client.get("/categories/list") - - @task - def add_category(self): - """ - Task to simulate adding a category - """ - csrf_token = self._get_csrf_token_for_form("/categories/edit") - name = self._get_name("Category") - self.client.post("/categories/edit", data={ - "name": name, - "csrf_token": csrf_token - }) - - @task - def list_species(self): - """ - Task to simulate listing the species belonging to a selected category - """ - csrf_token = self._get_csrf_token_for_form("/species/list") - category_id = self._get_random_category_id() - self.client.post("/species/list", data={ - "category": str(category_id), - "csrf_token": csrf_token - }) - - @task - def add_species(self): - """ - Task to simulate adding a species - """ - csrf_token = self._get_csrf_token_for_form("/species/add") - category_id = self._get_random_category_id() - name = self._get_name("Species") - self.client.post("/species/add", data={ - "category": str(category_id), - "name": name, - "csrf_token": csrf_token - }) - - @task(20) - def list_sightings(self): - """ - Task to simulate listing the sightings - """ - self.client.get("/sightings/list") - - @task(70) - def add_sighting(self): - """ - Task to simulate adding a new sighting - """ - csrf_token = self._get_csrf_token_for_form("/sightings/edit") - sighting_date = datetime.datetime.today().strftime(Sighting.DATE_DISPLAY_FORMAT) - location_id = self._get_random_location_id() - category_id = self._get_random_category_id() - species_id = self._get_random_species_id(category_id) - self.client.post("/sightings/edit", data={ - "date": sighting_date, - "location": str(location_id), - "category": str(category_id), - "species": str(species_id), - "number": "1", - "gender": "0", - "with_young": "0", - "notes": "", - "csrf_token": csrf_token - }) - - @task - def list_conservation_status_schemes(self): - """ - Task to simulate listing the conservation status schemes - """ - self.client.get("/status/list") - - @task - def show_life_list(self): - """ - Task to simulate showing the life list for a category - """ - csrf_token = self._get_csrf_token_for_form("/life_list/list") - category_id = self._get_random_category_id() - self.client.post("/life_list/list", data={ - "category": str(category_id), - "csrf_token": csrf_token - }) - - @task - def list_recent_background_jobs(self): - """ - Task to simulate listing the recent background jobs - """ - self.client.get("/jobs/list") - - def _login(self): - """ - Log in using the test account - """ - csrf_token = self._get_csrf_token_for_form("/auth/login") - self.client.post("/auth/login", data={ - "username": TEST_USER_NAME, - "password": TEST_PASSWORD, - "csrf_token": csrf_token - }) - - def _get_csrf_token_for_form(self, url): - """ - Request a page containing a form and return the CSRF token from it - - :param url: URL for the page containing the form - :return: CSFR token - """ - response = self.client.get(url) - form_data = BeautifulSoup(response.text, "html.parser") - csrf_token_field = form_data.find(attrs={"name": "csrf_token"}) - return csrf_token_field["value"] - - def _get_random_location_id(self): - """ - Return a random location ID for an existing location - """ - index = randrange(0, len(self._locations)) - return self._locations[index].id - - def _get_random_category_id(self): - """ - Return a random category ID for an existing category - """ - index = randrange(0, len(self._categories)) - return self._categories[index].id - - @staticmethod - def _get_random_species_id(category_id): - """ - Return a random species ID for species in the specified category - - :param category_id: Category ID from which to select a species - """ - species = list_species(category_id) - index = randrange(0, len(species)) - return species[index].id - - def _get_name(self, prefix): - """ - Construct a unique name for a new record with the specified prefix - - :param prefix: Prefix indicating the record type - :return: Unique record name - """ - return f"{prefix} - {id(self)} - {int(time.time())}" +import os +import time +import datetime +from bs4 import BeautifulSoup +from random import randrange +from locust import HttpUser, task, between, events +from locust_tests.flask_app_runner import FlaskAppRunner +from src.naturerec_model.model import create_database, get_data_path, Sighting +from src.naturerec_model.logic import list_locations, list_categories, list_species, create_user +from src.naturerec_model.data_exchange import SightingsImportHelper, StatusImportHelper +from src.naturerec_web import create_app + +TEST_USER_NAME = "locust" +TEST_PASSWORD = "password" + + +flask_runner = FlaskAppRunner("127.0.0.1", 5000, create_app()) + + +@events.test_start.add_listener +def on_test_start(environment, **kwargs): + """ + Before any tests run, start the Flask application + + :param environment: Ignored + :param kwargs: Ignored + """ + # Reset the database + create_database() + + # Create a login + create_user(TEST_USER_NAME, TEST_PASSWORD) + + # Import some sample sightings + sightings_file = os.path.join(get_data_path(), "imports", "locust_sightings.csv") + with open(sightings_file, mode="rt", encoding="utf-8") as f: + importer = SightingsImportHelper(f) + importer.start() + importer.join() + + # Import a sample conservation status scheme + scheme_file = os.path.join(get_data_path(), "imports", "locust_bocc5.csv") + with open(scheme_file, mode="rt", encoding="utf-8") as f: + importer = StatusImportHelper(f) + importer.start() + importer.join() + + # Start the site + global flask_runner + flask_runner.start() + + +@events.test_stop.add_listener +def on_test_stop(environment, **kwargs): + """ + When the tests complete, stop the Flask application + + :param environment: Ignored + :param kwargs: Ignored + """ + global flask_runner + flask_runner.stop_server() + flask_runner.join() + + +class NatureRecorderUser(HttpUser): + """ + Locust load test, targeting the Nature Recorder application hosted locally in the flask development server. The + tests are weighted as follows: + + +----------------------------------+----+ + | Test | % | + +----------------------------------+----+ + | go_to_home_page | 1 | + +----------------------------------+----+ + | list_locations | 1 | + +----------------------------------+----+ + | add_location | 1 | + +----------------------------------+----+ + | list_categories | 1 | + +----------------------------------+----+ + | add_category | 1 | + +----------------------------------+----+ + | list_species | 1 | + +----------------------------------+----+ + | add_species | 1 | + +----------------------------------+----+ + | list_sightings | 20 | + +----------------------------------+----+ + | add_sighting | 70 | + +----------------------------------+----+ + | list_conservation_status_schemes | 1 | + +----------------------------------+----+ + | show_life_list | 1 | + +----------------------------------+----+ + | list_recent_background_jobs | 1 | + +----------------------------------+----+ + """ + + #: Simulated users will wait between 1 and 5 seconds per task + wait_time = between(1, 5) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._locations = None + self._categories = None + + def on_start(self): + """ + Establish some facts about the data so the tests can simulate realistic actions + """ + self._locations = list_locations() + self._categories = list_categories() + self._login() + + @task + def go_to_home_page(self): + """ + Task to open the home page of the application + """ + self.client.get("/") + + @task + def list_locations(self): + """ + Task to simulate listing the locations + """ + self.client.get("/locations/list") + + @task + def add_location(self): + """ + Task to simulate adding a location + """ + csrf_token = self._get_csrf_token_for_form("/locations/edit") + name = self._get_name("Location") + county = self._get_name("County") + country = self._get_name("Country") + self.client.post("/locations/edit", data={ + "name": name, + "address": "", + "city": "", + "county": county, + "postcode": "", + "country": country, + "latitude": "", + "longitude": "", + "csrf_token": csrf_token + }) + + @task + def list_categories(self): + """ + Task to simulate listing the categories + """ + self.client.get("/categories/list") + + @task + def add_category(self): + """ + Task to simulate adding a category + """ + csrf_token = self._get_csrf_token_for_form("/categories/edit") + name = self._get_name("Category") + self.client.post("/categories/edit", data={ + "name": name, + "csrf_token": csrf_token + }) + + @task + def list_species(self): + """ + Task to simulate listing the species belonging to a selected category + """ + csrf_token = self._get_csrf_token_for_form("/species/list") + category_id = self._get_random_category_id() + self.client.post("/species/list", data={ + "category": str(category_id), + "csrf_token": csrf_token + }) + + @task + def add_species(self): + """ + Task to simulate adding a species + """ + csrf_token = self._get_csrf_token_for_form("/species/add") + category_id = self._get_random_category_id() + name = self._get_name("Species") + self.client.post("/species/add", data={ + "category": str(category_id), + "name": name, + "csrf_token": csrf_token + }) + + @task(20) + def list_sightings(self): + """ + Task to simulate listing the sightings + """ + self.client.get("/sightings/list") + + @task(70) + def add_sighting(self): + """ + Task to simulate adding a new sighting + """ + csrf_token = self._get_csrf_token_for_form("/sightings/edit") + sighting_date = datetime.datetime.today().strftime(Sighting.DATE_DISPLAY_FORMAT) + location_id = self._get_random_location_id() + category_id = self._get_random_category_id() + species_id = self._get_random_species_id(category_id) + self.client.post("/sightings/edit", data={ + "date": sighting_date, + "location": str(location_id), + "category": str(category_id), + "species": str(species_id), + "number": "1", + "gender": "0", + "with_young": "0", + "notes": "", + "csrf_token": csrf_token + }) + + @task + def list_conservation_status_schemes(self): + """ + Task to simulate listing the conservation status schemes + """ + self.client.get("/status/list") + + @task + def show_life_list(self): + """ + Task to simulate showing the life list for a category + """ + csrf_token = self._get_csrf_token_for_form("/life_list/list") + category_id = self._get_random_category_id() + self.client.post("/life_list/list", data={ + "category": str(category_id), + "csrf_token": csrf_token + }) + + @task + def list_recent_background_jobs(self): + """ + Task to simulate listing the recent background jobs + """ + self.client.get("/jobs/list") + + def _login(self): + """ + Log in using the test account + """ + csrf_token = self._get_csrf_token_for_form("/auth/login") + self.client.post("/auth/login", data={ + "username": TEST_USER_NAME, + "password": TEST_PASSWORD, + "csrf_token": csrf_token + }) + + def _get_csrf_token_for_form(self, url): + """ + Request a page containing a form and return the CSRF token from it + + :param url: URL for the page containing the form + :return: CSFR token + """ + response = self.client.get(url) + form_data = BeautifulSoup(response.text, "html.parser") + csrf_token_field = form_data.find(attrs={"name": "csrf_token"}) + return csrf_token_field["value"] + + def _get_random_location_id(self): + """ + Return a random location ID for an existing location + """ + index = randrange(0, len(self._locations)) + return self._locations[index].id + + def _get_random_category_id(self): + """ + Return a random category ID for an existing category + """ + index = randrange(0, len(self._categories)) + return self._categories[index].id + + @staticmethod + def _get_random_species_id(category_id): + """ + Return a random species ID for species in the specified category + + :param category_id: Category ID from which to select a species + """ + species = list_species(category_id) + index = randrange(0, len(species)) + return species[index].id + + def _get_name(self, prefix): + """ + Construct a unique name for a new record with the specified prefix + + :param prefix: Prefix indicating the record type + :return: Unique record name + """ + return f"{prefix} - {id(self)} - {int(time.time())}" diff --git a/tests/naturerec_model/logic/test_status_schemes.py b/tests/naturerec_model/logic/test_status_schemes.py index 47062ac..2735c91 100644 --- a/tests/naturerec_model/logic/test_status_schemes.py +++ b/tests/naturerec_model/logic/test_status_schemes.py @@ -1,105 +1,105 @@ -import unittest -import datetime -from src.naturerec_model.model import create_database, Session, StatusScheme -from src.naturerec_model.logic import create_category -from src.naturerec_model.logic import create_species -from src.naturerec_model.logic import create_species_status_rating -from src.naturerec_model.logic import create_status_rating -from src.naturerec_model.logic import create_status_scheme, get_status_scheme, list_status_schemes, \ - update_status_scheme, delete_status_scheme - - -class TestStatusSchemes(unittest.TestCase): - def setUp(self) -> None: - create_database() - self._scheme = create_status_scheme("BOCC4") - - def test_can_create_scheme(self): - with Session.begin() as session: - scheme = session.query(StatusScheme).one() - self.assertEqual("BOCC4", scheme.name) - - def test_can_update_scheme(self): - with Session.begin() as session: - scheme_id = session.query(StatusScheme).one().id - _ = update_status_scheme(scheme_id, "Some Scheme") - scheme = get_status_scheme(scheme_id) - self.assertEqual("Some Scheme", scheme.name) - - def test_cannot_update_scheme_to_create_duplicate(self): - scheme = create_status_scheme("Some Scheme") - with self.assertRaises(ValueError): - _ = update_status_scheme(scheme.id, "BOCC4") - - def test_cannot_update_missing_scheme(self): - with self.assertRaises(ValueError): - _ = update_status_scheme(-1, "Some Scheme") - - def test_cannot_update_scheme_with_none_name(self): - with self.assertRaises(ValueError), Session.begin() as session: - scheme_id = session.query(StatusScheme).one().id - _ = update_status_scheme(scheme_id, None) - - def test_cannot_update_scheme_with_blank_name(self): - with self.assertRaises(ValueError), Session.begin() as session: - scheme_id = session.query(StatusScheme).one().id - _ = update_status_scheme(scheme_id, "") - - def test_cannot_update_scheme_with_whitespace_name(self): - with self.assertRaises(ValueError), Session.begin() as session: - scheme_id = session.query(StatusScheme).one().id - _ = update_status_scheme(scheme_id, " ") - - def test_can_get_scheme_by_name(self): - scheme = get_status_scheme("BOCC4") - self.assertEqual("BOCC4", scheme.name) - - def test_cannot_get_missing_scheme_by_name(self): - with self.assertRaises(ValueError): - _ = get_status_scheme("") - - def test_can_get_scheme_by_id(self): - with Session.begin() as session: - scheme_id = session.query(StatusScheme).one().id - scheme = get_status_scheme(scheme_id) - self.assertEqual("BOCC4", scheme.name) - - def test_cannot_get_missing_scheme_by_id(self): - with self.assertRaises(ValueError): - _ = get_status_scheme(-1) - - def test_cannot_get_scheme_by_invalid_identifier(self): - with self.assertRaises(TypeError): - _ = get_status_scheme([]) - - def test_can_list_schemes(self): - schemes = list_status_schemes() - self.assertEqual(1, len(schemes)) - self.assertEqual("BOCC4", schemes[0].name) - - def test_can_delete_scheme(self): - schemes = list_status_schemes() - self.assertEqual(1, len(schemes)) - delete_status_scheme(self._scheme.id) - schemes = list_status_schemes() - self.assertEqual(0, len(schemes)) - - def test_cannot_delete_missing_scheme(self): - with self.assertRaises(ValueError): - delete_status_scheme(-1) - - def test_can_delete_scheme_with_unused_ratings(self): - _ = create_status_rating(self._scheme.id, "Amber") - schemes = list_status_schemes() - self.assertEqual(1, len(schemes)) - delete_status_scheme(self._scheme.id) - schemes = list_status_schemes() - self.assertEqual(0, len(schemes)) - - def test_cannot_delete_scheme_with_species_ratings(self): - category = create_category("Birds") - species = create_species(category.id, "Reed Bunting") - rating = create_status_rating(self._scheme.id, "Amber") - _ = create_species_status_rating(species.id, rating.id, "United Kingdom", datetime.date(2015, 1, 1)) - with self.assertRaises(ValueError): - delete_status_scheme(self._scheme.id) +import unittest +import datetime +from src.naturerec_model.model import create_database, Session, StatusScheme +from src.naturerec_model.logic import create_category +from src.naturerec_model.logic import create_species +from src.naturerec_model.logic import create_species_status_rating +from src.naturerec_model.logic import create_status_rating +from src.naturerec_model.logic import create_status_scheme, get_status_scheme, list_status_schemes, \ + update_status_scheme, delete_status_scheme + + +class TestStatusSchemes(unittest.TestCase): + def setUp(self) -> None: + create_database() + self._scheme = create_status_scheme("BOCC4") + + def test_can_create_scheme(self): + with Session.begin() as session: + scheme = session.query(StatusScheme).one() + self.assertEqual("BOCC4", scheme.name) + + def test_can_update_scheme(self): + with Session.begin() as session: + scheme_id = session.query(StatusScheme).one().id + _ = update_status_scheme(scheme_id, "Some Scheme") + scheme = get_status_scheme(scheme_id) + self.assertEqual("Some Scheme", scheme.name) + + def test_cannot_update_scheme_to_create_duplicate(self): + scheme = create_status_scheme("Some Scheme") + with self.assertRaises(ValueError): + _ = update_status_scheme(scheme.id, "BOCC4") + + def test_cannot_update_missing_scheme(self): + with self.assertRaises(ValueError): + _ = update_status_scheme(-1, "Some Scheme") + + def test_cannot_update_scheme_with_none_name(self): + with self.assertRaises(ValueError), Session.begin() as session: + scheme_id = session.query(StatusScheme).one().id + _ = update_status_scheme(scheme_id, None) + + def test_cannot_update_scheme_with_blank_name(self): + with self.assertRaises(ValueError), Session.begin() as session: + scheme_id = session.query(StatusScheme).one().id + _ = update_status_scheme(scheme_id, "") + + def test_cannot_update_scheme_with_whitespace_name(self): + with self.assertRaises(ValueError), Session.begin() as session: + scheme_id = session.query(StatusScheme).one().id + _ = update_status_scheme(scheme_id, " ") + + def test_can_get_scheme_by_name(self): + scheme = get_status_scheme("BOCC4") + self.assertEqual("BOCC4", scheme.name) + + def test_cannot_get_missing_scheme_by_name(self): + with self.assertRaises(ValueError): + _ = get_status_scheme("") + + def test_can_get_scheme_by_id(self): + with Session.begin() as session: + scheme_id = session.query(StatusScheme).one().id + scheme = get_status_scheme(scheme_id) + self.assertEqual("BOCC4", scheme.name) + + def test_cannot_get_missing_scheme_by_id(self): + with self.assertRaises(ValueError): + _ = get_status_scheme(-1) + + def test_cannot_get_scheme_by_invalid_identifier(self): + with self.assertRaises(TypeError): + _ = get_status_scheme([]) + + def test_can_list_schemes(self): + schemes = list_status_schemes() + self.assertEqual(1, len(schemes)) + self.assertEqual("BOCC4", schemes[0].name) + + def test_can_delete_scheme(self): + schemes = list_status_schemes() + self.assertEqual(1, len(schemes)) + delete_status_scheme(self._scheme.id) + schemes = list_status_schemes() + self.assertEqual(0, len(schemes)) + + def test_cannot_delete_missing_scheme(self): + with self.assertRaises(ValueError): + delete_status_scheme(-1) + + def test_can_delete_scheme_with_unused_ratings(self): + _ = create_status_rating(self._scheme.id, "Amber") + schemes = list_status_schemes() + self.assertEqual(1, len(schemes)) + delete_status_scheme(self._scheme.id) + schemes = list_status_schemes() + self.assertEqual(0, len(schemes)) + + def test_cannot_delete_scheme_with_species_ratings(self): + category = create_category("Birds") + species = create_species(category.id, "Reed Bunting") + rating = create_status_rating(self._scheme.id, "Amber") + _ = create_species_status_rating(species.id, rating.id, "United Kingdom", datetime.date(2015, 1, 1)) + with self.assertRaises(ValueError): + delete_status_scheme(self._scheme.id)