diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index d79a58e..a20400f 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -2,9 +2,9 @@ name: "CodeQL" on: push: - branches: [ "develop", "master" ] + branches: ["develop", "master"] pull_request: - branches: [ "develop" ] + branches: ["develop"] schedule: - cron: "17 4 * * 5" @@ -20,11 +20,11 @@ jobs: strategy: fail-fast: false matrix: - language: [ javascript, python ] + language: [javascript, python] steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Initialize CodeQL uses: github/codeql-action/init@v2 diff --git a/.github/workflows/pythonpublish.yml b/.github/workflows/pythonpublish.yml index 4e1ef42..ce25ff7 100644 --- a/.github/workflows/pythonpublish.yml +++ b/.github/workflows/pythonpublish.yml @@ -13,9 +13,9 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: '3.x' - name: Install dependencies diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 76317ed..852f325 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,23 +4,24 @@ on: - pull_request jobs: - build: + build-and-test: runs-on: ubuntu-latest + container: mcr.microsoft.com/playwright:v1.42.1-jammy strategy: matrix: - python-version: ["3.7", "3.8", "3.9", "3.10", "pypy-3.7", "pypy-3.8"] + python-version: ["3.8", "3.9", "3.10", "pypy-3.8"] steps: - - uses: actions/checkout@v1 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install tox tox-gh-actions coveralls - - name: Test with tox - run: tox -p auto - env: - TOX_PARALLEL_NO_SPINNER: 1 + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install tox tox-gh-actions coveralls + - name: Test with tox + run: tox -p auto + env: + TOX_PARALLEL_NO_SPINNER: 1 diff --git a/.gitignore b/.gitignore index 0eee564..011d710 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ django_advanced_filters.egg-info/ tests/db.sqlite* /.python-version /.cache/ +__pycache__/ /.eggs/ /htmlcov/ /advanced_filters/.coverage @@ -18,5 +19,6 @@ tests/db.sqlite* /tests/local.db /.venv .vscode/settings.json +Sublimerge.vcs-cache pyrightconfig.json .ropeproject diff --git a/.isort.cfg b/.isort.cfg new file mode 100644 index 0000000..e4e613b --- /dev/null +++ b/.isort.cfg @@ -0,0 +1,11 @@ +[settings] +line_length=88 +combine_as_imports = yes +default_section = THIRDPARTY +include_trailing_comma = yes +known_django = django +known_first_party = tests,advanced_filters +multi_line_output = 3 +no_lines_before=LOCALFOLDER +sections = FUTURE,STDLIB,THIRDPARTY,DJANGO,FIRSTPARTY,LOCALFOLDER +skip_glob = */migrations/*.py diff --git a/advanced_filters/tests/conftest.py b/advanced_filters/tests/conftest.py deleted file mode 100644 index 6db2510..0000000 --- a/advanced_filters/tests/conftest.py +++ /dev/null @@ -1,13 +0,0 @@ -import pytest -from tests.factories import SalesRepFactory - - -@pytest.fixture -def user(db): - return SalesRepFactory() - - -@pytest.fixture() -def client(client, user): - client.force_login(user) - return client diff --git a/conftest.py b/advanced_filters/tests/integration/__init__.py similarity index 100% rename from conftest.py rename to advanced_filters/tests/integration/__init__.py diff --git a/advanced_filters/tests/integration/conftest.py b/advanced_filters/tests/integration/conftest.py new file mode 100644 index 0000000..ff85890 --- /dev/null +++ b/advanced_filters/tests/integration/conftest.py @@ -0,0 +1,25 @@ +import pytest + +from tests.factories import ClientFactory, SalesRepFactory + + +@pytest.fixture(scope="session") +def base_url(live_server): + return live_server.url + + +@pytest.fixture +@pytest.mark.usefixtures("db") +def user(): + return SalesRepFactory() + + +@pytest.fixture() +def client(client, user): + client.force_login(user) + return client + + +@pytest.fixture +def three_clients(user): + return ClientFactory.create_batch(3, assigned_to=user) diff --git a/advanced_filters/tests/test_admin_change_form.py b/advanced_filters/tests/integration/test_admin_change_form.py similarity index 85% rename from advanced_filters/tests/test_admin_change_form.py rename to advanced_filters/tests/integration/test_admin_change_form.py index fd4c248..6e8294e 100644 --- a/advanced_filters/tests/test_admin_change_form.py +++ b/advanced_filters/tests/integration/test_admin_change_form.py @@ -3,8 +3,8 @@ from django.db.models import Q from django.urls import reverse -from ..models import AdvancedFilter -from .factories import AdvancedFilterFactory +from advanced_filters.models import AdvancedFilter +from advanced_filters.tests.factories import AdvancedFilterFactory URL_NAME_CHANGE = "admin:advanced_filters_advancedfilter_change" URL_NAME_ADD = "admin:advanced_filters_advancedfilter_add" @@ -42,7 +42,9 @@ def test_change_and_goto(client, user, settings, advanced_filter): res = client.post(url, data=form_data) assert res.status_code == 302 url = res["location"] - assert url.endswith("%s?_afilter=1" % reverse(URL_NAME_CLIENT_CHANGELIST)) + changelist_url = reverse(URL_NAME_CLIENT_CHANGELIST) + new_filter_id = AdvancedFilter.objects.last().pk + assert url.endswith(f"{changelist_url}?_afilter={new_filter_id}") def test_create_page_disabled(client, user): diff --git a/advanced_filters/tests/test_creation.py b/advanced_filters/tests/integration/test_creation.py similarity index 98% rename from advanced_filters/tests/test_creation.py rename to advanced_filters/tests/integration/test_creation.py index af1805c..f08fb82 100644 --- a/advanced_filters/tests/test_creation.py +++ b/advanced_filters/tests/integration/test_creation.py @@ -2,7 +2,7 @@ from django.contrib.auth.models import Permission from django.urls import reverse_lazy -from ..models import AdvancedFilter +from advanced_filters.models import AdvancedFilter URL_CLIENT_CHANGELIST = reverse_lazy("admin:customers_client_changelist") diff --git a/advanced_filters/tests/test_get_field_choices_view.py b/advanced_filters/tests/integration/test_get_field_choices_view.py similarity index 91% rename from advanced_filters/tests/test_get_field_choices_view.py rename to advanced_filters/tests/integration/test_get_field_choices_view.py index d4aa954..14414a6 100644 --- a/advanced_filters/tests/test_get_field_choices_view.py +++ b/advanced_filters/tests/integration/test_get_field_choices_view.py @@ -1,26 +1,28 @@ import json -import sys from datetime import timedelta -from operator import attrgetter +from operator import itemgetter -import django import factory import pytest +from django.urls import reverse from django.utils import timezone from django.utils.encoding import force_str -from django.urls import reverse -from tests.factories import ClientFactory +from tests.factories import ClientFactory URL_NAME = "afilters_get_field_choices" +def parse_json(content): + return json.loads(force_str(content)) + + def assert_json(content, expect): - assert json.loads(force_str(content)) == expect + assert parse_json(content) == expect def assert_view_error(client, error, exception=None, **view_kwargs): - """ Ensure view either raises exception or returns a 400 json error """ + """Ensure view either raises exception or returns a 400 json error""" view_url = reverse(URL_NAME, kwargs=view_kwargs) if exception is not None: @@ -71,11 +73,6 @@ def test_field_with_choices(client): ) -@pytest.fixture -def three_clients(user): - return ClientFactory.create_batch(3, assigned_to=user) - - def test_disabled_field(three_clients, client, settings): settings.ADVANCED_FILTERS_DISABLE_FOR_FIELDS = ("email",) view_url = reverse( @@ -98,10 +95,10 @@ def test_database_choices(three_clients, client): URL_NAME, kwargs=dict(model="customers.Client", field_name="email") ) response = client.get(view_url) - assert_json( - response.content, - {"results": [dict(id=e.email, text=e.email) for e in three_clients]}, - ) + result = parse_json(response.content) + data = (dict(id=e.email, text=e.email) for e in three_clients) + sort_func = itemgetter("id") + assert sorted(result["results"], key=sort_func) == sorted(data, key=sort_func) def test_more_than_max_database_choices(user, client, settings): diff --git a/advanced_filters/tests/integration/test_list_page.py b/advanced_filters/tests/integration/test_list_page.py new file mode 100644 index 0000000..0a30eb0 --- /dev/null +++ b/advanced_filters/tests/integration/test_list_page.py @@ -0,0 +1,72 @@ +import re + +from django.contrib.auth.models import Permission +from playwright.sync_api import Page, expect +import pytest + +TEXT = { + "login_page_title": re.compile(r".*Log in.*"), + "client_page_title": re.compile(r".*Select client to change.*"), + "advanced_filter_button_text": "Advanced Filter", + "advanced_filter_modal_heading": "Create advanced filter", + "advanced_filter_title_label": "Title", + "advanced_filter_add_another_rule_link": "Add another filter", +} +SELECTOR = { + "modal_id": "#advanced_filters", +} + +expect.set_options(timeout=2_000) + + +@pytest.fixture(autouse=True) +def grant_permissions(user): + # grant permission to our admin user + user.user_permissions.add(Permission.objects.get(codename="change_client")) + + +def authenticate(base_url, page: Page): + page.goto(f"{base_url}/admin/") + # Expect a title "to contain" a substring. + expect(page).to_have_title(TEXT["login_page_title"]) + # Expect auth form to have a well known structure + page.get_by_label("Username").fill("user") + page.get_by_label("Password").fill("test") + page.get_by_text("Log in").click() + expect(page.get_by_text("Welcome, user")).not_to_be_empty() + + +@pytest.mark.only_browser("chromium") +def test_advanced_filter_modal_shown(page: Page, base_url): + # GIVEN a logged in user + authenticate(base_url, page) + + # WHEN the user navigates to the list page + page.goto(f"{base_url}/admin/customers/client/") + + # THEN the client list page should load + expect(page).to_have_title(TEXT["client_page_title"]) + # the page should contain an unordered list with a link to the filter + tools_list = page.get_by_role("listitem").filter( + has_text=TEXT["advanced_filter_button_text"] + ) + advanced_filter_link = tools_list.get_by_role( + "link", name=TEXT["advanced_filter_button_text"] + ) + expect(advanced_filter_link).to_be_visible() + # when the button is clicked, the modal is displayed + advanced_filter_link.click() + modal = page.locator(SELECTOR["modal_id"]) + # the modal contains a heading + expect( + modal.get_by_role("heading", name=TEXT["advanced_filter_modal_heading"]) + ).to_be_visible() + # and a form with a mandatory title field + expect(modal.get_by_label(TEXT["advanced_filter_title_label"])).to_be_visible() + # TODO: the following assertion demonstrates a bug + # below it, the modal contains a blank "extra" filter, with the required fields + # expect(modal.locator("select[name=form-0-field]")).to_be_visible() + # and a button to add another filter + expect( + modal.get_by_role("link", name=TEXT["advanced_filter_add_another_rule_link"]) + ).to_be_visible() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..17fdabf --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,2 @@ +[tool.black] +skip-string-normalization = "true" \ No newline at end of file diff --git a/pytest.ini b/pytest.ini index 2d341e0..151b768 100644 --- a/pytest.ini +++ b/pytest.ini @@ -3,3 +3,8 @@ DJANGO_SETTINGS_MODULE=tests.test_project.settings addopts = --cov=advanced_filters --cov-report=term-missing --doctest-modules testpaths = advanced_filters pythonpath = . tests + +env = + D:CI=true + D:MOZ_HEADLESS=1 + DJANGO_ALLOW_ASYNC_UNSAFE=true diff --git a/test-reqs.txt b/test-reqs.txt index ceef0c1..2c90e69 100644 --- a/test-reqs.txt +++ b/test-reqs.txt @@ -3,3 +3,6 @@ factory-boy==3.3.0 pycodestyle==2.10.0 pytest-django==4.5.2 pytest-cov==4.1.0 +pytest-playwright==0.4.4 +pytest-env==1.1.3 +tzdata==2024.1 diff --git a/tests/factories.py b/tests/factories.py index d5b2eb3..00e3e12 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -13,14 +13,11 @@ class Meta: is_superuser = False @classmethod - def _prepare(cls, create, **kwargs): - password = kwargs.pop('password', None) - user = super()._prepare(create, **kwargs) - if password: - user.set_password(password) - if create: - user.save() - return user + def _create(cls, model_class, *args, **kwargs): + """Override the default ``_create`` with our custom call.""" + manager = cls._get_manager(model_class) + # avoid ``manager.create(*args, **kwargs)`` = encrypt password + return manager.create_user(*args, **kwargs) class ClientFactory(factory.django.DjangoModelFactory): diff --git a/tox.ini b/tox.ini index 4236a10..f0f29d8 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,5 @@ [tox] envlist = - py{37,py37}-django{22,32} py{38,39}-django{22,32,40} pypy38-django22 py310-django{32,40} @@ -11,6 +10,7 @@ max-line-length = 120 [testenv] usedevelop = true +passenv = CI, PLAYWRIGHT_BROWSERS_PATH deps = -rtest-reqs.txt django22: Django>=2.2,<3.0 @@ -29,11 +29,9 @@ commands = [gh-actions] python = - 3.7: py37 3.8: py38 3.9: py39 3.10: py310 - pypy-3.7: pypy37 pypy-3.8: pypy38 [gh-actions:env]