diff --git a/.env b/.env new file mode 100644 index 0000000..7421224 --- /dev/null +++ b/.env @@ -0,0 +1,12 @@ +# Flask server configuration. +FLASK_APP=todo_app/app +FLASK_ENV=development + +# Change the following values for local development. +SECRET_KEY=#5fjA@kA#PnACdvM + +TRELLO_KEY=b2913e0f31ec7f6dbbed884d221d0c80 +TRELLO_TOKEN=ed0e04c8d3fd0e66e43c6a3bb8aa2ad890945d66a856916725280a7ff83d0b80 + +BOARD_ID=AVaJn7PF +ORGANIZATION_ID=project2290 \ No newline at end of file diff --git a/.env.template b/.env.template index d2b3880..140fbae 100644 --- a/.env.template +++ b/.env.template @@ -10,4 +10,8 @@ TRELLO_KEY=trello-key TRELLO_TOKEN=trello-token # The short board id to use for this app -BOARD_ID=abcdefgh \ No newline at end of file +BOARD_ID=abcdefgh + +# The organization (aka workspace) id to use for creating test boards +# Only needed for testing +ORGANIZATION_ID=abcdefg \ No newline at end of file diff --git a/poetry.lock b/poetry.lock index 6838adc..d8d0fd5 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,3 +1,11 @@ +[[package]] +name = "async-generator" +version = "1.10" +description = "Async generators and context managers for Python 3.5+" +category = "dev" +optional = false +python-versions = ">=3.5" + [[package]] name = "atomicwrites" version = "1.4.1" @@ -28,6 +36,17 @@ category = "main" optional = false python-versions = ">=3.6" +[[package]] +name = "cffi" +version = "1.15.1" +description = "Foreign Function Interface for Python calling C code." +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +pycparser = "*" + [[package]] name = "charset-normalizer" version = "2.1.0" @@ -55,6 +74,25 @@ category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +[[package]] +name = "cryptography" +version = "37.0.4" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +cffi = ">=1.12" + +[package.extras] +docs = ["sphinx (>=1.6.5,!=1.8.0,!=3.1.0,!=3.1.1)", "sphinx-rtd-theme"] +docstest = ["pyenchant (>=1.6.11)", "twine (>=1.12.0)", "sphinxcontrib-spelling (>=4.0.1)"] +pep8test = ["black", "flake8", "flake8-import-order", "pep8-naming"] +sdist = ["setuptools_rust (>=0.11.4)"] +ssh = ["bcrypt (>=3.1.5)"] +test = ["pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-subtests", "pytest-xdist", "pretend", "iso8601", "pytz", "hypothesis (>=1.11.4,!=3.79.2)"] + [[package]] name = "flask" version = "2.0.3" @@ -73,6 +111,17 @@ Werkzeug = ">=2.0" async = ["asgiref (>=3.2)"] dotenv = ["python-dotenv"] +[[package]] +name = "h11" +version = "0.13.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +typing-extensions = {version = "*", markers = "python_version < \"3.8\""} + [[package]] name = "idna" version = "3.3" @@ -136,6 +185,17 @@ category = "main" optional = false python-versions = ">=3.7" +[[package]] +name = "outcome" +version = "1.2.0" +description = "Capture the outcome of Python function calls." +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +attrs = ">=19.2.0" + [[package]] name = "packaging" version = "21.3" @@ -170,6 +230,29 @@ category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +[[package]] +name = "pycparser" +version = "2.21" +description = "C parser in Python" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "pyopenssl" +version = "22.0.0" +description = "Python wrapper module around the OpenSSL library" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +cryptography = ">=35.0" + +[package.extras] +docs = ["sphinx", "sphinx-rtd-theme"] +test = ["flaky", "pretend", "pytest (>=3.0.1)"] + [[package]] name = "pyparsing" version = "3.0.9" @@ -181,6 +264,14 @@ python-versions = ">=3.6.8" [package.extras] diagrams = ["railroad-diagrams", "jinja2"] +[[package]] +name = "pysocks" +version = "1.7.1" +description = "A Python SOCKS client module. See https://github.com/Anorov/PySocks for more information." +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + [[package]] name = "pytest" version = "7.1.2" @@ -257,6 +348,19 @@ urllib3 = ">=1.21.1,<1.27" socks = ["PySocks (>=1.5.6,!=1.5.7)"] use_chardet_on_py3 = ["chardet (>=3.0.2,<6)"] +[[package]] +name = "selenium" +version = "4.3.0" +description = "" +category = "dev" +optional = false +python-versions = "~=3.7" + +[package.dependencies] +trio = ">=0.17,<1.0" +trio-websocket = ">=0.9,<1.0" +urllib3 = {version = ">=1.26,<2.0", extras = ["secure", "socks"]} + [[package]] name = "six" version = "1.16.0" @@ -265,6 +369,22 @@ category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +[[package]] +name = "sniffio" +version = "1.2.0" +description = "Sniff out which async library your code is running under" +category = "dev" +optional = false +python-versions = ">=3.5" + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +description = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set" +category = "dev" +optional = false +python-versions = "*" + [[package]] name = "tomli" version = "2.0.1" @@ -273,6 +393,36 @@ category = "dev" optional = false python-versions = ">=3.7" +[[package]] +name = "trio" +version = "0.21.0" +description = "A friendly Python library for async concurrency and I/O" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +async-generator = ">=1.9" +attrs = ">=19.2.0" +cffi = {version = ">=1.14", markers = "os_name == \"nt\" and implementation_name != \"pypy\""} +idna = "*" +outcome = "*" +sniffio = "*" +sortedcontainers = "*" + +[[package]] +name = "trio-websocket" +version = "0.9.2" +description = "WebSocket library for Trio" +category = "dev" +optional = false +python-versions = ">=3.5" + +[package.dependencies] +async-generator = ">=1.10" +trio = ">=0.11" +wsproto = ">=0.14" + [[package]] name = "typing-extensions" version = "4.3.0" @@ -289,11 +439,30 @@ category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, <4" +[package.dependencies] +certifi = {version = "*", optional = true, markers = "extra == \"secure\""} +cryptography = {version = ">=1.3.4", optional = true, markers = "extra == \"secure\""} +idna = {version = ">=2.0.0", optional = true, markers = "extra == \"secure\""} +pyOpenSSL = {version = ">=0.14", optional = true, markers = "extra == \"secure\""} +PySocks = {version = ">=1.5.6,<1.5.7 || >1.5.7,<2.0", optional = true, markers = "extra == \"socks\""} + [package.extras] brotli = ["brotlicffi (>=0.8.0)", "brotli (>=1.0.9)", "brotlipy (>=0.6.0)"] secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] +[[package]] +name = "webdriver-manager" +version = "3.8.1" +description = "Library provides the way to automatically manage drivers for different browsers" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +python-dotenv = "*" +requests = "*" + [[package]] name = "werkzeug" version = "2.0.3" @@ -305,6 +474,17 @@ python-versions = ">=3.6" [package.extras] watchdog = ["watchdog"] +[[package]] +name = "wsproto" +version = "1.1.0" +description = "WebSockets state-machine based protocol implementation" +category = "dev" +optional = false +python-versions = ">=3.7.0" + +[package.dependencies] +h11 = ">=0.9.0,<1" + [[package]] name = "zipp" version = "3.8.1" @@ -320,34 +500,49 @@ testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest- [metadata] lock-version = "1.1" python-versions = "^3.7" -content-hash = "afb7e4dcf79ed8f5c42861b1494fd99768c1008229e22613227d3c0264437b23" +content-hash = "f933ff07b6d0fd62f7827b6e5eabbea8e8c01b4ab339a92eab42dc37d01c3cf4" [metadata.files] +async-generator = [] atomicwrites = [] attrs = [] certifi = [] +cffi = [] charset-normalizer = [] click = [] colorama = [] +cryptography = [] flask = [] +h11 = [] idna = [] importlib-metadata = [] iniconfig = [] itsdangerous = [] jinja2 = [] markupsafe = [] +outcome = [] packaging = [] pluggy = [] py = [] +pycparser = [] +pyopenssl = [] pyparsing = [] +pysocks = [] pytest = [] pytest-mock = [] python-dateutil = [] python-dotenv = [] requests = [] +selenium = [] six = [] +sniffio = [] +sortedcontainers = [] tomli = [] +trio = [] +trio-websocket = [] typing-extensions = [] urllib3 = [] +webdriver-manager = [] werkzeug = [] +wsproto = [] zipp = [] diff --git a/pyproject.toml b/pyproject.toml index 2c177b4..c4bc562 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,8 @@ python-dateutil = "^2.8.2" [tool.poetry.dev-dependencies] pytest = "^7.1.2" pytest-mock = "^3.8.2" +selenium = "^4.3.0" +webdriver-manager = "^3.8.1" [build-system] requires = ["poetry>=0.12"] diff --git a/selenium_tests/__init__.py b/selenium_tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/selenium_tests/app_test.py b/selenium_tests/app_test.py new file mode 100644 index 0000000..c56d51b --- /dev/null +++ b/selenium_tests/app_test.py @@ -0,0 +1,220 @@ +from os import environ +from threading import Thread +from time import sleep + +from _pytest.monkeypatch import MonkeyPatch +from requests import post, delete +from selenium import webdriver +from selenium.webdriver.common.by import By +from selenium.webdriver.remote.webelement import WebElement +from webdriver_manager.microsoft import EdgeChromiumDriverManager + +from todo_app import app + +import pytest + +from todo_app.data import session_tasks +from todo_app.data.env import get_default_params, get_organization_id, get_board_id +from todo_app.data.session_tasks import BOARD_URL, BOARDS_URL, toggle + + +def create_test_board(): + create_params = { + **get_default_params(), + 'idOrganization': get_organization_id(), + 'defaultLists': "false", + 'name': "TEST" + } + + response = post(BOARDS_URL, params=create_params).json() + + return response['id'] + + +def delete_test_board(id): + delete(BOARD_URL.format(id=id), params=get_default_params()) + + +@pytest.fixture(scope='module') +def app_with_temp_board(): + board_id = create_test_board() + + environ["BOARD_ID"] = board_id + + thread = Thread(target=lambda: todo_app.run(use_reloader=False)) + + todo_app = app.create_app() + + thread.daemon = True + thread.start() + + sleep(1) + + yield todo_app + + thread.join(1) + + delete_test_board(board_id) + + +@pytest.fixture(scope='module') +def driver(): + with webdriver.Edge(EdgeChromiumDriverManager().install()) as driver: + yield driver + + +def add_task(name, driver): + add_name = driver.find_element(By.ID, "itemtitle") + add_name.send_keys(name) + add_button = driver.find_element(By.ID, "btn-add") + add_button.click() + + +def find_task_element(name, driver, button_name): + label = driver.find_element(By.NAME, name) + parent: WebElement = label.find_element(By.XPATH, "./..") + button = parent.find_element(By.NAME, button_name) + return button + + +def task_is(status, name, driver): + wanted_list = driver.find_element(By.ID, status) + unwanted_list = driver.find_element(By.ID, toggle(status)) + assert wanted_list.find_elements(By.NAME, name) + assert not unwanted_list.find_elements(By.NAME, name) + + +def task_exists(name, driver): + assert driver.find_elements(By.NAME, name) + + +def task_not_exists(name, driver): + assert not driver.find_elements(By.NAME, name) + + +def test_app_loads(driver, app_with_temp_board): + driver.get("http://localhost:5000") + + assert driver.title == 'To-Do App' + + +def test_add_element(driver, app_with_temp_board): + driver.get("http://localhost:5000") + + add_task("This is a hard task", driver) + + task_exists("This is a hard task", driver) + + +def test_delete_element(driver, app_with_temp_board): + delete_me = "Please delete this task" + + driver.get("http://localhost:5000") + + add_task(delete_me, driver) + + assert delete_me in driver.page_source + + button = find_task_element(delete_me, driver, "delete") + button.click() + + task_not_exists(delete_me, driver) + + +def test_delete_element_with_others_on_page(driver, app_with_temp_board): + dont_delete_me = "Please DO NOT delete this task" + delete_me = "Please delete this task" + dont_delete_me2 = "Also please DO NOT delete this task either" + + driver.get("http://localhost:5000") + + add_task(dont_delete_me, driver) + add_task(delete_me, driver) + add_task(dont_delete_me2, driver) + + task_exists(delete_me, driver) + task_exists(dont_delete_me, driver) + task_exists(dont_delete_me2, driver) + + button = find_task_element(delete_me, driver, "delete") + button.click() + + task_not_exists(delete_me, driver) + task_exists(dont_delete_me, driver) + task_exists(dont_delete_me2, driver) + + +def test_new_task_goes_to_incomplete(driver, app_with_temp_board): + this_is_a_new_incomplete_task = "This is a new incomplete task" + + driver.get("http://localhost:5000") + + add_task(this_is_a_new_incomplete_task, driver) + + task_is("incomplete", this_is_a_new_incomplete_task, driver) + + +def test_switching_between_tasks(driver, app_with_temp_board): + this_is_an_incomplete_task = "This is an incomplete task" + + driver.get("http://localhost:5000") + + add_task(this_is_an_incomplete_task, driver) + + task_is("incomplete", this_is_an_incomplete_task, driver) + + change_status_button = find_task_element(this_is_an_incomplete_task, driver, "changestatus") + change_status_button.click() + + task_is("complete", this_is_an_incomplete_task, driver) + + +def test_adding_description(driver, app_with_temp_board): + rename_me = "Rename me" + new_description = "New description" + + driver.get("http://localhost:5000") + + add_task(rename_me, driver) + + edit_button = find_task_element(rename_me, driver, "edit") + edit_button.click() + + description_box = find_task_element(rename_me, driver, "description") + description_box.send_keys(new_description) + + submit_button = find_task_element(rename_me, driver, "submit") + submit_button.click() + + assert new_description in driver.page_source + + +def test_editing_description(driver, app_with_temp_board): + please_change_my_description = "Please change my description" + temp_description = "Temporary description" + replacement_description = "Replacement description" + + driver.get("http://localhost:5000") + + add_task(please_change_my_description, driver) + + edit_button = find_task_element(please_change_my_description, driver, "edit") + edit_button.click() + + description_box = find_task_element(please_change_my_description, driver, "description") + description_box.send_keys(temp_description) + + submit_button = find_task_element(please_change_my_description, driver, "submit") + submit_button.click() + + edit_button = find_task_element(please_change_my_description, driver, "edit") + edit_button.click() + + description_box = find_task_element(please_change_my_description, driver, "description") + description_box.clear() + description_box.send_keys(replacement_description) + + submit_button = find_task_element(please_change_my_description, driver, "submit") + submit_button.click() + + assert replacement_description in driver.page_source diff --git a/todo_app/app.py b/todo_app/app.py index 65dedf0..ba34817 100644 --- a/todo_app/app.py +++ b/todo_app/app.py @@ -11,8 +11,6 @@ def create_app(): app = Flask(__name__) app.config.from_object(Config()) - session_tasks.init_env() - @app.route('/') def index(): tasks_view_model = ViewModel(get_tasks()) diff --git a/todo_app/data/env.py b/todo_app/data/env.py new file mode 100644 index 0000000..88b7eec --- /dev/null +++ b/todo_app/data/env.py @@ -0,0 +1,16 @@ +from os import getenv + + +def get_board_id(): + return getenv("BOARD_ID") + + +def get_organization_id(): + return getenv("ORGANIZATION_ID") + + +def get_default_params(): + return { + 'key': getenv("TRELLO_KEY"), + 'token': getenv("TRELLO_TOKEN") + } \ No newline at end of file diff --git a/todo_app/data/trello_tasks.py b/todo_app/data/trello_tasks.py index 994d5ca..7a13fba 100644 --- a/todo_app/data/trello_tasks.py +++ b/todo_app/data/trello_tasks.py @@ -1,46 +1,21 @@ from json import JSONDecodeError -from os import getenv from typing import List import requests +from todo_app.data.env import get_default_params, get_board_id from todo_app.data.task import Task BASE_URL = "https://api.trello.com/1/" -BOARD_ID = None - -BOARD_URL = None -BOARD_LISTS_URL = None -LISTS_URL = None -LIST_CARDS_URL = None -CARD_URL = None -CARDS_URL = None - -DEFAULT_PARAMS = { - 'key': getenv("TRELLO_KEY"), - 'token': getenv("TRELLO_TOKEN") -} - - -def init_env(): - global BOARD_ID, BASE_URL, BOARD_URL, BOARD_LISTS_URL, LISTS_URL, \ - LIST_CARDS_URL, CARD_URL, CARDS_URL, DEFAULT_PARAMS - - BOARD_ID = getenv("BOARD_ID") - - BOARD_URL = "%sboards/{id}" % BASE_URL - BOARD_LISTS_URL = "%sboards/{id}/lists" % BASE_URL - LISTS_URL = "%slists" % BASE_URL - LIST_CARDS_URL = "%slists/{id}/cards" % BASE_URL - CARD_URL = "%scards/{id}" % BASE_URL - CARDS_URL = "%scards" % BASE_URL - - DEFAULT_PARAMS = { - 'key': getenv("TRELLO_KEY"), - 'token': getenv("TRELLO_TOKEN") - } +BOARD_URL = "%sboards/{id}" % BASE_URL +BOARDS_URL = "%sboards" % BASE_URL +BOARD_LISTS_URL = "%sboards/{id}/lists" % BASE_URL +LISTS_URL = "%slists" % BASE_URL +LIST_CARDS_URL = "%slists/{id}/cards" % BASE_URL +CARD_URL = "%scards/{id}" % BASE_URL +CARDS_URL = "%scards" % BASE_URL def toggle(status): @@ -52,14 +27,14 @@ def toggle(status): def get_or_create_list(name): - response = requests.get(BOARD_LISTS_URL.format(id=BOARD_ID), params=DEFAULT_PARAMS).json() + response = requests.get(BOARD_LISTS_URL.format(id=get_board_id()), params=get_default_params()).json() id = next((list['id'] for list in response if list['name'] == name), None) # if this is ie a new board without the list made yet if id is None: create_list_params = { - **DEFAULT_PARAMS, + **get_default_params(), 'name': name, 'idBoard': get_long_board_id() } @@ -85,14 +60,14 @@ def get_long_board_id(): global _long_board_id if _long_board_id is None: - response = requests.get(BOARD_URL.format(id=BOARD_ID), params=DEFAULT_PARAMS).json() + response = requests.get(BOARD_URL.format(id=get_board_id()), params=get_default_params()).json() _long_board_id = response["id"] return _long_board_id def get_tasks_from_list(id, status): - response = requests.get(LIST_CARDS_URL.format(id=id), params=DEFAULT_PARAMS).json() + response = requests.get(LIST_CARDS_URL.format(id=id), params=get_default_params()).json() return [Task.from_card(card, status) for card in response] @@ -122,7 +97,7 @@ def get_task(id) -> Task | None: """ try: - response = requests.get(CARD_URL.format(id=id), params=DEFAULT_PARAMS).json() + response = requests.get(CARD_URL.format(id=id), params=get_default_params()).json() except JSONDecodeError: return None @@ -150,7 +125,7 @@ def add_task(title): todo_list_id, _ = get_list_ids() add_params = { - **DEFAULT_PARAMS, + **get_default_params(), 'idList': todo_list_id, 'name': title } @@ -168,7 +143,7 @@ def delete_task(id): id: The ID of the task. """ - requests.delete(CARD_URL.format(id=id), params=DEFAULT_PARAMS) + requests.delete(CARD_URL.format(id=id), params=get_default_params()) def save_task(task: Task): @@ -184,7 +159,7 @@ def save_task(task: Task): list_id = done_list_id if task.status == "complete" else todo_list_id update_params = { - **DEFAULT_PARAMS, + **get_default_params(), 'name': task.title, 'idList': list_id, 'desc': task.description, diff --git a/todo_app/templates/index.html b/todo_app/templates/index.html index f94acb1..0a6f6d1 100644 --- a/todo_app/templates/index.html +++ b/todo_app/templates/index.html @@ -12,9 +12,9 @@