aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitlab-ci.yml6
-rw-r--r--fietsboek/templates/edit_form.jinja22
-rw-r--r--poetry.lock103
-rw-r--r--pyproject.toml1
-rw-r--r--tests/playwright/conftest.py48
-rw-r--r--tests/playwright/test_basic.py184
6 files changed, 340 insertions, 4 deletions
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 9cd9587..2226c44 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -21,12 +21,14 @@ before_script:
test:
script:
- - tox -e python
+ - pip install playwright && playwright install firefox && playwright install-deps
+ - tox -e python -- --browser firefox
test-pypy:
image: pypy:3
script:
- - tox -e pypy3
+ - pip install playwright && playwright install firefox && playwright install-deps
+ - tox -e pypy3 -- --browser firefox
lint:
script:
diff --git a/fietsboek/templates/edit_form.jinja2 b/fietsboek/templates/edit_form.jinja2
index f7dd007..bfd45a1 100644
--- a/fietsboek/templates/edit_form.jinja2
+++ b/fietsboek/templates/edit_form.jinja2
@@ -29,7 +29,7 @@
</select>
</div>
<div class="mb-3">
- <div>{{ _("page.track.form.tags") }}</div>
+ <label for="new-tag" class="form-label">{{ _("page.track.form.tags") }}</label>
<div id="formTags" class="mb-1">
{% for tag in tags %}
<span class="tag-badge badge rounded-pill bg-info text-dark">{{ tag }} <i class="bi bi-x"></i><input type="hidden" value="{{ tag }}" name="tag[]"></span>
diff --git a/poetry.lock b/poetry.lock
index 327b7e5..596cd1a 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -520,6 +520,19 @@ docs = ["furo (>=2022.9.29)", "proselint (>=0.13)", "sphinx (>=5.3)", "sphinx-au
test = ["appdirs (==1.4.4)", "pytest (>=7.2)", "pytest-cov (>=4)", "pytest-mock (>=3.10)"]
[[package]]
+name = "playwright"
+version = "1.28.0"
+description = "A high-level API to automate web browsers"
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+
+[package.dependencies]
+greenlet = "2.0.1"
+pyee = "9.0.4"
+typing-extensions = {version = "*", markers = "python_version <= \"3.8\""}
+
+[[package]]
name = "pluggy"
version = "1.0.0"
description = "plugin and hook calling mechanisms for python"
@@ -558,6 +571,17 @@ dotenv = ["python-dotenv (>=0.10.4)"]
email = ["email-validator (>=1.0.3)"]
[[package]]
+name = "pyee"
+version = "9.0.4"
+description = "A port of node.js's EventEmitter to python."
+category = "dev"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+typing-extensions = "*"
+
+[[package]]
name = "pygments"
version = "2.13.0"
description = "Pygments is a syntax highlighting package written in Python."
@@ -731,6 +755,18 @@ tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""}
testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"]
[[package]]
+name = "pytest-base-url"
+version = "2.0.0"
+description = "pytest plugin for URL based testing"
+category = "dev"
+optional = false
+python-versions = ">=3.7,<4.0"
+
+[package.dependencies]
+pytest = ">=3.0.0,<8.0.0"
+requests = ">=2.9"
+
+[[package]]
name = "pytest-cov"
version = "4.0.0"
description = "Pytest plugin for measuring coverage."
@@ -746,6 +782,34 @@ pytest = ">=4.6"
testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"]
[[package]]
+name = "pytest-playwright"
+version = "0.3.0"
+description = "A pytest wrapper with fixtures for Playwright to automate web browsers"
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+
+[package.dependencies]
+playwright = ">=1.18"
+pytest = "*"
+pytest-base-url = "*"
+python-slugify = "*"
+
+[[package]]
+name = "python-slugify"
+version = "7.0.0"
+description = "A Python slugify application that also handles Unicode"
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+
+[package.dependencies]
+text-unidecode = ">=1.3"
+
+[package.extras]
+unidecode = ["Unidecode (>=1.1.1)"]
+
+[[package]]
name = "pytz"
version = "2022.6"
description = "World timezone definitions, modern and historical"
@@ -986,6 +1050,14 @@ python-versions = ">=3.7"
tests = ["pytest", "pytest-cov"]
[[package]]
+name = "text-unidecode"
+version = "1.3"
+description = "The most basic Text::Unidecode port"
+category = "dev"
+optional = false
+python-versions = "*"
+
+[[package]]
name = "tomli"
version = "2.0.1"
description = "A lil' TOML parser"
@@ -1251,7 +1323,7 @@ test = ["zope.testing"]
[metadata]
lock-version = "1.1"
python-versions = "^3.7.2"
-content-hash = "8f437e4819a20de837375ca3cdf59530da0f190389484750eb32998b9930a964"
+content-hash = "20da091813c8e88b93d645201acd1c5c00ec4316188ed64fd67447da1bac6a1f"
[metadata.files]
alabaster = [
@@ -1706,6 +1778,15 @@ platformdirs = [
{file = "platformdirs-2.6.0-py3-none-any.whl", hash = "sha256:1a89a12377800c81983db6be069ec068eee989748799b946cce2a6e80dcc54ca"},
{file = "platformdirs-2.6.0.tar.gz", hash = "sha256:b46ffafa316e6b83b47489d240ce17173f123a9b9c83282141c3daf26ad9ac2e"},
]
+playwright = [
+ {file = "playwright-1.28.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:2e101b17e4d5252ef96c9dc8b2ac17f2980dde0420728c1c96a77eeaf6f9b11f"},
+ {file = "playwright-1.28.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:265f47aaa42c7986316100f5f468f8654e9a1609c2a2578743e25d058bddc1e6"},
+ {file = "playwright-1.28.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:a21ddd7b6f6afd434a73471f7cd39673286f0ca88b62b756d90264eb7b5a7daf"},
+ {file = "playwright-1.28.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:96a2d63954098233bbfc48b874f2a8e7cf0c64d7fcae24469571b0fb90ebe00f"},
+ {file = "playwright-1.28.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:074f73c17971f233903949492f31113bfbc2f1e2e85da7c1c03a15e5008b529f"},
+ {file = "playwright-1.28.0-py3-none-win32.whl", hash = "sha256:8557d92718ce45814aff017fa1774ab92089e40b6c16a8073d5a7c4d583d4aed"},
+ {file = "playwright-1.28.0-py3-none-win_amd64.whl", hash = "sha256:794b9da616c03354a12e48ddf060a9e776ab59b90662b0131ff74ec1b25739f4"},
+]
pluggy = [
{file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"},
{file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"},
@@ -1752,6 +1833,10 @@ pydantic = [
{file = "pydantic-1.10.2-py3-none-any.whl", hash = "sha256:1b6ee725bd6e83ec78b1aa32c5b1fa67a3a65badddde3976bca5fe4568f27709"},
{file = "pydantic-1.10.2.tar.gz", hash = "sha256:91b8e218852ef6007c2b98cd861601c6a09f1aa32bbbb74fab5b1c33d4a1e410"},
]
+pyee = [
+ {file = "pyee-9.0.4-py2.py3-none-any.whl", hash = "sha256:9f066570130c554e9cc12de5a9d86f57c7ee47fece163bbdaa3e9c933cfbdfa5"},
+ {file = "pyee-9.0.4.tar.gz", hash = "sha256:2770c4928abc721f46b705e6a72b0c59480c4a69c9a83ca0b00bb994f1ea4b32"},
+]
pygments = [
{file = "Pygments-2.13.0-py3-none-any.whl", hash = "sha256:f643f331ab57ba3c9d89212ee4a2dabc6e94f117cf4eefde99a0574720d14c42"},
{file = "Pygments-2.13.0.tar.gz", hash = "sha256:56a8508ae95f98e2b9bdf93a6be5ae3f7d8af858b43e02c5a2ff083726be40c1"},
@@ -1792,10 +1877,22 @@ pytest = [
{file = "pytest-7.2.0-py3-none-any.whl", hash = "sha256:892f933d339f068883b6fd5a459f03d85bfcb355e4981e146d2c7616c21fef71"},
{file = "pytest-7.2.0.tar.gz", hash = "sha256:c4014eb40e10f11f355ad4e3c2fb2c6c6d1919c73f3b5a433de4708202cade59"},
]
+pytest-base-url = [
+ {file = "pytest-base-url-2.0.0.tar.gz", hash = "sha256:e1e88a4fd221941572ccdcf3bf6c051392d2f8b6cef3e0bc7da95abec4b5346e"},
+ {file = "pytest_base_url-2.0.0-py3-none-any.whl", hash = "sha256:ed36fd632c32af9f1c08f2c2835dcf42ca8fcd097d6ed44a09f253d365ad8297"},
+]
pytest-cov = [
{file = "pytest-cov-4.0.0.tar.gz", hash = "sha256:996b79efde6433cdbd0088872dbc5fb3ed7fe1578b68cdbba634f14bb8dd0470"},
{file = "pytest_cov-4.0.0-py3-none-any.whl", hash = "sha256:2feb1b751d66a8bd934e5edfa2e961d11309dc37b73b0eabe73b5945fee20f6b"},
]
+pytest-playwright = [
+ {file = "pytest-playwright-0.3.0.tar.gz", hash = "sha256:62b843b73d259431380393b335bee65cc7a420c9095c83f78274b6b508cb2f33"},
+ {file = "pytest_playwright-0.3.0-py3-none-any.whl", hash = "sha256:2f41058f4a769a2c9631baacec65e9ebb3255e34519f0aab7f46e4c7fe66d127"},
+]
+python-slugify = [
+ {file = "python-slugify-7.0.0.tar.gz", hash = "sha256:7a0f21a39fa6c1c4bf2e5984c9b9ae944483fd10b54804cb0e23a3ccd4954f0b"},
+ {file = "python_slugify-7.0.0-py2.py3-none-any.whl", hash = "sha256:003aee64f9fd955d111549f96c4b58a3f40b9319383c70fad6277a4974bbf570"},
+]
pytz = [
{file = "pytz-2022.6-py2.py3-none-any.whl", hash = "sha256:222439474e9c98fced559f1709d89e6c9cbf8d79c794ff3eb9f8800064291427"},
{file = "pytz-2022.6.tar.gz", hash = "sha256:e89512406b793ca39f5971bc999cc538ce125c0e51c27941bef4568b460095e2"},
@@ -1903,6 +2000,10 @@ termcolor = [
{file = "termcolor-2.1.1-py3-none-any.whl", hash = "sha256:fa852e957f97252205e105dd55bbc23b419a70fec0085708fc0515e399f304fd"},
{file = "termcolor-2.1.1.tar.gz", hash = "sha256:67cee2009adc6449c650f6bcf3bdeed00c8ba53a8cda5362733c53e0a39fb70b"},
]
+text-unidecode = [
+ {file = "text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93"},
+ {file = "text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8"},
+]
tomli = [
{file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"},
{file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"},
diff --git a/pyproject.toml b/pyproject.toml
index 067631f..e08b92b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -67,6 +67,7 @@ optional = true
pytest = "^7.2.0"
webtest = "^3.0.0"
pytest-cov = "^4.0.0"
+pytest-playwright = "^0.3.0"
[tool.poetry.group.linters]
optional = true
diff --git a/tests/playwright/conftest.py b/tests/playwright/conftest.py
new file mode 100644
index 0000000..dedaf22
--- /dev/null
+++ b/tests/playwright/conftest.py
@@ -0,0 +1,48 @@
+import socket
+import threading
+from wsgiref import simple_server
+
+import pytest
+
+
+@pytest.fixture(scope="session")
+def server_port():
+ """Return a (likely) free port.
+
+ Note that due to OS race conditions between picking the port and opening
+ something on it, it might be taken again, but that is unlikely.
+ """
+ sock = socket.socket(socket.AF_INET)
+ sock.bind(("", 0))
+ port = sock.getsockname()[1]
+ sock.close()
+ return port
+
+
+@pytest.fixture(scope="session", autouse=True)
+def running_server(server_port, app):
+ """Have the app running as an actual server."""
+ server = simple_server.make_server("127.0.0.1", server_port, app)
+ thread = threading.Thread(target=server.serve_forever)
+ thread.daemon = True
+ thread.start()
+ yield
+ server.shutdown()
+
+
+@pytest.fixture
+def browser_context_args(server_port) -> dict:
+ return {"base_url": f"http://localhost:{server_port}"}
+
+
+@pytest.fixture
+def dbaccess(app):
+ """Provide direct access to the database.
+
+ This is needed for the selenium tests, because the normal "dbsession"
+ fixture has a doomed transaction attached. This is nice for keeping the
+ test database clean, but it does mean that the changes are not propagated
+ through and the running WSGI app cannot read them.
+ """
+ session_factory = app.registry["dbsession_factory"]
+ return session_factory()
diff --git a/tests/playwright/test_basic.py b/tests/playwright/test_basic.py
new file mode 100644
index 0000000..b3e340d
--- /dev/null
+++ b/tests/playwright/test_basic.py
@@ -0,0 +1,184 @@
+import datetime
+
+import pytest
+from pyramid.authentication import AuthTktCookieHelper
+from pyramid.testing import DummyRequest
+from playwright.sync_api import Page, expect
+from sqlalchemy import select
+
+from testutils import load_gpx_asset
+from fietsboek import models
+from fietsboek.models.track import Visibility, TrackType
+from fietsboek.config import Config
+
+
+@pytest.fixture
+def john_doe(dbaccess):
+ """Provides a test user (John Doe).
+
+ This fixture either returns the existing John or creates a new one.
+ """
+ query = models.User.query_by_email("john@doe.com")
+ result = dbaccess.execute(query).scalar_one_or_none()
+ if result:
+ return result
+ with dbaccess:
+ user = models.User(name="John Doe", email="john@doe.com", is_verified=True)
+ user.set_password("password")
+ dbaccess.add(user)
+ dbaccess.commit()
+ return user
+
+
+def do_login(settings: dict, page: Page, user: models.User):
+ """Logs the given user in by setting the auth cookie."""
+ config = Config.construct(session_key=settings["session_key"])
+ secret = config.derive_secret("auth-cookie")
+ helper = AuthTktCookieHelper(secret)
+ headers = helper.remember(DummyRequest(), str(user.id))
+ for _, header_val in headers:
+ cookie = header_val.split(";")[0]
+ name, value = cookie.split("=", 1)
+ page.context.add_cookies([
+ {"name": name, "value": value, "domain": "localhost", "path": "/"},
+ ])
+
+
+def test_homepage(page: Page):
+ page.goto("/")
+ assert "Welcome to Fietsboek!" in page.content()
+ assert "Here you can …" in page.content()
+
+
+def test_login_failed(page: Page):
+ page.goto("/")
+ page.get_by_role("button", name="User").click()
+ page.get_by_text("Login").click()
+
+ page.get_by_label("E-Mail").fill("not-john@doe.com")
+ page.get_by_label("Password").fill("password")
+ page.get_by_role("button", name="Login").click()
+
+ expect(page.locator(".alert")).to_have_text("Invalid login credentials")
+
+
+def test_login_success(page: Page, john_doe):
+
+ page.goto("/")
+ page.get_by_role("button", name="User").click()
+ page.get_by_text("Login").click()
+
+ page.get_by_label("E-Mail").fill("john@doe.com")
+ page.get_by_label("Password").fill("password")
+ page.get_by_role("button", name="Login").click()
+
+ expect(page.locator(".alert")).to_have_text("You are now logged in")
+
+
+def test_upload(page: Page, john_doe, app_settings, tmp_path, dbaccess):
+ do_login(app_settings, page, john_doe)
+ page.goto("/")
+ page.get_by_text("Upload").click()
+
+ # We unpack one of the test GPX files
+ gpx_data = load_gpx_asset("Teasi_1.gpx.gz")
+ gpx_path = tmp_path / "Upload.gpx"
+ with open(gpx_path, "wb") as gpx_fobj:
+ gpx_fobj.write(gpx_data)
+
+ page.get_by_label("GPX file").set_input_files(gpx_path)
+ page.locator(".bi-upload").click()
+
+ # We now fill in most of the data
+ page.get_by_label("Title").fill("An awesome track!")
+ page.get_by_label("Date").type("07302022")
+ page.get_by_label("Date").press("Tab")
+ page.get_by_label("Date").type("12:41")
+ page.get_by_label("Visibility").select_option(label="Public")
+ page.get_by_label("Tags").fill("Tolle Tour")
+ page.get_by_role("button", name="Add Tag").click()
+ page.get_by_label("Description").fill("Beschreibung der tollen Tour")
+
+ page.locator(".btn", has_text="Upload").click()
+
+ # Once we have finished the upload, extract the ID of the track and check
+ # the properties
+ new_track_id = int(page.url.rsplit("/", 1)[1])
+ track = dbaccess.execute(select(models.Track).filter_by(id=new_track_id)).scalar_one()
+
+ assert track.title == "An awesome track!"
+ assert track.date.replace(tzinfo=None) == datetime.datetime(2022, 7, 30, 12, 41)
+ assert track.visibility == Visibility.PUBLIC
+ assert track.text_tags() == {"Tolle Tour"}
+ assert track.description == "Beschreibung der tollen Tour"
+
+
+def test_edit(page: Page, john_doe, app_settings, dbaccess):
+ do_login(app_settings, page, john_doe)
+ with dbaccess:
+ track = models.Track(
+ title="Another awesome track",
+ visibility=Visibility.PRIVATE,
+ description="Another description",
+ )
+ track.date = datetime.datetime.now(datetime.timezone.utc)
+ track.gpx_data = load_gpx_asset("Teasi_1.gpx.gz")
+ john_doe.tracks.append(track)
+ dbaccess.flush()
+ track_id = track.id
+ dbaccess.commit()
+
+ page.goto(f"/track/{track_id}")
+ page.locator(".btn", has_text="Edit").click()
+
+ # We now fill in most of the data
+ page.get_by_label("Title").fill("Not so awesome anymore!")
+ page.get_by_label("Date").type("09232019")
+ page.get_by_label("Date").press("Tab")
+ page.get_by_label("Date").type("15:28")
+ page.get_by_label("Visibility").select_option(label="Public")
+ page.get_by_label("Tags").fill("Shitty Tour")
+ page.get_by_role("button", name="Add Tag").click()
+ page.get_by_label("Description").fill("Not so descriptive anymore")
+
+ page.locator(".btn", has_text="Save").click()
+
+ track = dbaccess.execute(select(models.Track).filter_by(id=track_id)).scalar_one()
+
+ assert track.title == "Not so awesome anymore!"
+ assert track.date.replace(tzinfo=None) == datetime.datetime(2019, 9, 23, 15, 28)
+ assert track.visibility == Visibility.PUBLIC
+ assert track.text_tags() == {"Shitty Tour"}
+ assert track.description == "Not so descriptive anymore"
+
+
+def test_browse(page: Page, john_doe, app_settings, dbaccess):
+ do_login(app_settings, page, john_doe)
+ with dbaccess:
+ track = models.Track(
+ title="We're looking for this track",
+ visibility=Visibility.PRIVATE,
+ description="Another description",
+ type=TrackType.ORGANIC,
+ )
+ track.date = datetime.datetime.now(datetime.timezone.utc)
+ track.gpx_data = load_gpx_asset("Teasi_1.gpx.gz")
+ john_doe.tracks.append(track)
+ dbaccess.commit()
+
+ page.goto("/")
+ page.get_by_text("Browse").click()
+
+ expect(page.locator(".card-header", has_text="We're looking for this track")).to_be_visible()
+
+ page.get_by_role("textbox", name="Search terms").fill("Nothing")
+ page.get_by_role("button", name="Apply filters").click()
+
+ expect(
+ page.locator("p", has_text="No results matching the filters were found.")
+ ).to_be_visible()
+
+ page.get_by_role("textbox", name="Search terms").fill("looking for")
+ page.get_by_role("button", name="Apply filters").click()
+
+ expect(page.locator(".card-header", has_text="We're looking for this track")).to_be_visible()