diff --git a/Makefile b/Makefile index 21e39e844..7085bee58 100644 --- a/Makefile +++ b/Makefile @@ -82,7 +82,7 @@ dead-code: .PHONY: e2e-test e2e-test: export NEW_RELIC_ENVIRONMENT=test e2e-test: ## Run end-to-end integration tests; note that --browser webkit isn't currently working - poetry run pytest -v --browser chromium --browser firefox tests/end_to_end + poetry run pytest -vv --browser chromium --browser firefox tests/end_to_end .PHONY: js-lint js-lint: ## Run javascript linting scanners diff --git a/app/__init__.py b/app/__init__.py index 5292c767b..d2c61d0a0 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -52,6 +52,7 @@ from app.formatters import ( format_datetime_human, format_datetime_normal, format_datetime_relative, + format_datetime_scheduled_notification, format_datetime_table, format_day_of_week, format_delta, @@ -551,6 +552,7 @@ def add_template_filters(application): format_datetime, format_datetime_24h, format_datetime_normal, + format_datetime_scheduled_notification, format_datetime_table, valid_phone_number, linkable_name, diff --git a/app/formatters.py b/app/formatters.py index 2bb8854f3..397cbbc10 100644 --- a/app/formatters.py +++ b/app/formatters.py @@ -22,7 +22,7 @@ from notifications_utils.recipients import InvalidPhoneError, validate_phone_num from notifications_utils.take import Take from app.utils.csv import get_user_preferred_timezone -from app.utils.time import parse_dt, parse_naive_dt +from app.utils.time import parse_naive_dt def apply_html_class(tags, html_file): @@ -93,16 +93,34 @@ def format_datetime_normal(date): ) +def format_datetime_scheduled_notification(date): + # e.g. April 09, 2024 at 04:00 PM US/Eastern. + # Everything except scheduled notifications, the time is always "now". + # Scheduled notifications are the exception to the rule. + # Here we are formating and displaying the datetime without converting datetime to a different timezone. + + datetime_obj = parse_naive_dt(date) + + format_time_without_tz = datetime_obj.replace(tzinfo=timezone.utc).strftime( + "%I:%M %p" + ) + return "{} at {} {}".format( + format_date_normal(date), format_time_without_tz, get_user_preferred_timezone() + ) + + def format_datetime_table(date): # example: 03-18-2024 at 04:53 PM, intended for datetimes in tables return "{} at {}".format(format_date_numeric(date), format_time_12h(date)) def format_time_12h(date): - date = parse_dt(date) + date = parse_naive_dt(date) preferred_tz = pytz.timezone(get_user_preferred_timezone()) - return date.astimezone(preferred_tz).strftime("%I:%M %p") + return ( + date.replace(tzinfo=timezone.utc).astimezone(preferred_tz).strftime("%I:%M %p") + ) def format_datetime_relative(date): diff --git a/app/main/views/platform_admin.py b/app/main/views/platform_admin.py index 802ed5b18..6190ac988 100644 --- a/app/main/views/platform_admin.py +++ b/app/main/views/platform_admin.py @@ -662,7 +662,21 @@ def create_global_stats(services): "email": {"delivered": 0, "failed": 0, "requested": 0}, "sms": {"delivered": 0, "failed": 0, "requested": 0}, } + # Issue #1323. The back end is now sending 'failure' instead of + # 'failed'. Adjust it here, but keep it flexible in case + # the backend reverts to 'failed'. for service in services: + if service["statistics"]["sms"].get("failure") is not None: + service["statistics"]["sms"]["failed"] = service["statistics"]["sms"][ + "failure" + ] + if service["statistics"]["email"].get("failure") is not None: + service["statistics"]["email"]["failed"] = service["statistics"]["email"][ + "failure" + ] + + for service in services: + for msg_type, status in itertools.product( ("sms", "email"), ("delivered", "failed", "requested") ): diff --git a/app/main/views/send.py b/app/main/views/send.py index 78395d243..02f7d2121 100644 --- a/app/main/views/send.py +++ b/app/main/views/send.py @@ -650,7 +650,9 @@ def check_messages(service_id, template_id, upload_id, row_index=2): @user_has_permissions("send_messages", restrict_admin_usage=True) def preview_job(service_id, template_id, upload_id, row_index=2): session["scheduled_for"] = request.form.get("scheduled_for", "") - data = _check_messages(service_id, template_id, upload_id, row_index, force_hide_sender=True) + data = _check_messages( + service_id, template_id, upload_id, row_index, force_hide_sender=True + ) return render_template( "views/check/preview.html", @@ -911,7 +913,9 @@ def preview_notification(service_id, template_id): return render_template( "views/notifications/preview.html", - **_check_notification(service_id, template_id, show_recipient=False, force_hide_sender=True), + **_check_notification( + service_id, template_id, show_recipient=False, force_hide_sender=True + ), scheduled_for=session["scheduled_for"], recipient=recipient, ) diff --git a/app/main/views/sign_in.py b/app/main/views/sign_in.py index 402e61a08..6de46b023 100644 --- a/app/main/views/sign_in.py +++ b/app/main/views/sign_in.py @@ -63,6 +63,10 @@ def _get_access_token(code, state): url = f"{base_url}{cli_assert}&{cli_assert_type}&{code_param}&grant_type=authorization_code" headers = {"Authorization": "Bearer %s" % token} response = requests.post(url, headers=headers) + if response.json().get("access_token") is None: + # Capture the response json here so it hopefully shows up in error reports + current_app.logger.error(f"Error when getting access token {response.json()}") + raise KeyError(f"'access_token' {response.json()}") access_token = response.json()["access_token"] return access_token @@ -84,13 +88,17 @@ def _do_login_dot_gov(): code = request.args.get("code") state = request.args.get("state") login_gov_error = request.args.get("error") - if code and state: - access_token = _get_access_token(code, state) - user_email, user_uuid = _get_user_email_and_uuid(access_token) - redirect_url = request.args.get("next") + + if login_gov_error: + current_app.logger.error(f"login.gov error: {login_gov_error}") + raise Exception(f"Could not login with login.gov {login_gov_error}") + elif code and state: # activate the user try: + access_token = _get_access_token(code, state) + user_email, user_uuid = _get_user_email_and_uuid(access_token) + redirect_url = request.args.get("next") user = user_api_client.get_user_by_uuid_or_email(user_uuid, user_email) activate_user(user["id"]) except BaseException as be: # noqa B036 @@ -99,9 +107,6 @@ def _do_login_dot_gov(): return redirect(url_for("main.show_accounts_or_dashboard", next=redirect_url)) - elif login_gov_error: - current_app.logger.error(f"login.gov error: {login_gov_error}") - raise Exception(f"Could not login with login.gov {login_gov_error}") # end login.gov diff --git a/app/templates/new/components/main_nav.html b/app/templates/new/components/main_nav.html new file mode 100644 index 000000000..a3b02823e --- /dev/null +++ b/app/templates/new/components/main_nav.html @@ -0,0 +1,30 @@ +{% if help %} + {% include 'partials/tour.html' %} +{% else %} + +{% endif %} diff --git a/app/templates/new/components/org_nav.html b/app/templates/new/components/org_nav.html new file mode 100644 index 000000000..f7f7a5b93 --- /dev/null +++ b/app/templates/new/components/org_nav.html @@ -0,0 +1,11 @@ + diff --git a/app/templates/new/components/service_navigation.html b/app/templates/new/components/service_navigation.html new file mode 100644 index 000000000..e3826d279 --- /dev/null +++ b/app/templates/new/components/service_navigation.html @@ -0,0 +1,15 @@ + diff --git a/app/templates/new/components/settings_navigation.html b/app/templates/new/components/settings_navigation.html new file mode 100644 index 000000000..d94c417e7 --- /dev/null +++ b/app/templates/new/components/settings_navigation.html @@ -0,0 +1,16 @@ +{% if help %} +{% include 'partials/tour.html' %} +{% else %} + +{% endif %} diff --git a/app/templates/new/layouts/org_template.html b/app/templates/new/layouts/org_template.html index 5e04003fd..69f49b76a 100644 --- a/app/templates/new/layouts/org_template.html +++ b/app/templates/new/layouts/org_template.html @@ -1,4 +1,4 @@ -{% extends "base.html" %} +{% extends "/new/base.html" %} {% block per_page_title %} {% block org_page_title %}{% endblock %} – {{ current_org.name }} @@ -17,7 +17,7 @@
- {% include "org_nav.html" %} + {% include "/new/components/org_nav.html" %}
{% block beforeContent %} diff --git a/app/templates/new/layouts/withnav_template.html b/app/templates/new/layouts/withnav_template.html index 412a004d0..d06d069e9 100644 --- a/app/templates/new/layouts/withnav_template.html +++ b/app/templates/new/layouts/withnav_template.html @@ -1,4 +1,4 @@ -{% extends "base.html" %} +{% extends "/new/base.html" %} {% block per_page_title %} {% block service_page_title %}{% endblock %} – {{ current_service.name }} @@ -7,9 +7,9 @@ {% block main %}
{% block serviceNavigation %} - {% include "service_navigation.html" %} + {% include "new/components/service_navigation.html" %} {% endblock %} - +
{% if help %}
@@ -17,8 +17,8 @@
{% endif %} {% block sideNavigation %} - {% include "main_nav.html" %} - + {% include "/new/components/main_nav.html" %} + {% endblock %}
{% if help %} diff --git a/app/templates/new/templates_glossary.md b/app/templates/new/templates_glossary.md index 04f2c4151..1c52e75dc 100644 --- a/app/templates/new/templates_glossary.md +++ b/app/templates/new/templates_glossary.md @@ -14,6 +14,10 @@ This document serves as a glossary for the templates directory structure of the - `head.html`: Template for the site's , included in `base.html`. - `header.html`: Template for the site's header, included in `base.html`. - `footer.html`: Template for the site's footer, included in `base.html`. + - `settings_navigation.html`: The settings navigation used in `withnav_template.html` that previously extended `settings_template.html`. + - `org_nav.html`: The organization's navigation used solely in `org_template.html`. + - `main_nav.html`: The main navigation used in `withnav_template.html` + - `service_navigation.html`: The service navigation used in `withnav_template.html`. In withnav_template.html, the `serviceNavigation` block will be left empty in any child templates that previously extended `settings_template.html`. - **/views** (or **/pages**): Individual page templates that use the base layouts, components, and partials to present content. ### Best Practices @@ -30,9 +34,9 @@ This document serves as a glossary for the templates directory structure of the - withoutnav_template.html Delete - main_template.html Delete - settings_templates.html `withnav_template` can be used to replace `settings_template`. -- settings_nav.html (move to /new/navigation directory) -- main_nav.html (move to /new/navigation directory) -- service_navigation.html (move to /new/navigation directory) +- settings_nav.html (move to /components/ directory) +- main_nav.html (move to /components/ directory) +- service_navigation.html (move to /components/ directory) - org_template, could be under it's own directory called /layout/organization -- org_nav.html (move to /new/navigation directory) +- org_nav.html (move to /components/ directory) - content_template.html Delete diff --git a/app/templates/views/check/preview.html b/app/templates/views/check/preview.html index b0f58157d..32b158013 100644 --- a/app/templates/views/check/preview.html +++ b/app/templates/views/check/preview.html @@ -21,7 +21,7 @@ {{ page_header('Preview') }}
-

Scheduled: {{ scheduled_for |format_datetime_normal if scheduled_for else 'Now'}}

+

Scheduled: {{ scheduled_for |format_datetime_scheduled_notification if scheduled_for else 'Now'}}

File: {{original_file_name}}

Template: {{template.name}}

From: {{ template.sender }}

diff --git a/app/templates/views/notifications/preview.html b/app/templates/views/notifications/preview.html index c1fec9e20..ce94a606f 100644 --- a/app/templates/views/notifications/preview.html +++ b/app/templates/views/notifications/preview.html @@ -43,7 +43,7 @@ {{ page_header('Preview') }} {% endif %}
-

Scheduled: {{ scheduled_for |format_datetime_normal if scheduled_for else 'Now'}}

+

Scheduled: {{ scheduled_for |format_datetime_scheduled_notification if scheduled_for else 'Now'}}

Template: {{template.name}}

From: {{ template.sender }}

To: {{ recipient }}

diff --git a/app/utils/templates.py b/app/utils/templates.py index 355f658c0..f610398b7 100644 --- a/app/utils/templates.py +++ b/app/utils/templates.py @@ -17,7 +17,7 @@ def get_template( redact_missing_personalisation=False, email_reply_to=None, sms_sender=None, - force_hide_sender=False + force_hide_sender=False, ): if "email" == template["template_type"]: return EmailPreviewTemplate( diff --git a/app/utils/time.py b/app/utils/time.py index 05f190f15..9ae24b1bd 100644 --- a/app/utils/time.py +++ b/app/utils/time.py @@ -22,8 +22,3 @@ def is_less_than_days_ago(date_from_db, number_of_days): def parse_naive_dt(dt): return parser.parse(dt, ignoretz=True) - - -def parse_dt(dt): - # Parse datetime without ignoring the timezone - return parser.parse(dt) diff --git a/poetry.lock b/poetry.lock index 58a71aec6..21064bf51 100644 --- a/poetry.lock +++ b/poetry.lock @@ -171,17 +171,17 @@ files = [ [[package]] name = "boto3" -version = "1.34.79" +version = "1.34.83" description = "The AWS SDK for Python" optional = false python-versions = ">=3.8" files = [ - {file = "boto3-1.34.79-py3-none-any.whl", hash = "sha256:265b0b4865e8c07e27abb32a31d2bd9129bb009b1d89ca0783776ec084886123"}, - {file = "boto3-1.34.79.tar.gz", hash = "sha256:139dd2d94eaa0e3213ff37ba7cf4cb2e3823269178fe8f3e33c965f680a9ddde"}, + {file = "boto3-1.34.83-py3-none-any.whl", hash = "sha256:33cf93f6de5176f1188c923f4de1ae149ed723b89ed12e434f2b2f628491769e"}, + {file = "boto3-1.34.83.tar.gz", hash = "sha256:9733ce811bd82feab506ad9309e375a79cabe8c6149061971c17754ce8997551"}, ] [package.dependencies] -botocore = ">=1.34.79,<1.35.0" +botocore = ">=1.34.83,<1.35.0" jmespath = ">=0.7.1,<2.0.0" s3transfer = ">=0.10.0,<0.11.0" @@ -190,13 +190,13 @@ crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] [[package]] name = "botocore" -version = "1.34.79" +version = "1.34.83" description = "Low-level, data-driven core of boto 3." optional = false python-versions = ">=3.8" files = [ - {file = "botocore-1.34.79-py3-none-any.whl", hash = "sha256:a42a014d3dbaa9ef123810592af69f9e55b456c5be3ac9efc037325685519e83"}, - {file = "botocore-1.34.79.tar.gz", hash = "sha256:6b59b0f7de219d383a2a633f6718c2600642ebcb707749dc6c67a6a436474b7a"}, + {file = "botocore-1.34.83-py3-none-any.whl", hash = "sha256:0a3fbbe018416aeefa8978454fb0b8129adbaf556647b72269bf02e4bf1f4161"}, + {file = "botocore-1.34.83.tar.gz", hash = "sha256:0f302aa76283d4df62b4fbb6d3d20115c1a8957fc02171257fc93904d69d5636"}, ] [package.dependencies] @@ -1080,13 +1080,13 @@ license = ["ukkonen"] [[package]] name = "idna" -version = "3.6" +version = "3.7" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.5" files = [ - {file = "idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f"}, - {file = "idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca"}, + {file = "idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0"}, + {file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"}, ] [[package]] @@ -1516,6 +1516,7 @@ files = [ {file = "msgpack-1.0.8-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5fbb160554e319f7b22ecf530a80a3ff496d38e8e07ae763b9e82fadfe96f273"}, {file = "msgpack-1.0.8-cp39-cp39-win32.whl", hash = "sha256:f9af38a89b6a5c04b7d18c492c8ccf2aee7048aff1ce8437c4683bb5a1df893d"}, {file = "msgpack-1.0.8-cp39-cp39-win_amd64.whl", hash = "sha256:ed59dd52075f8fc91da6053b12e8c89e37aa043f8986efd89e61fae69dc1b011"}, + {file = "msgpack-1.0.8-py3-none-any.whl", hash = "sha256:24f727df1e20b9876fa6e95f840a2a2651e34c0ad147676356f4bf5fbb0206ca"}, {file = "msgpack-1.0.8.tar.gz", hash = "sha256:95c02b0e27e706e48d0e5426d1710ca78e0f0628d6e89d5b5a5b91a5f12274f3"}, ] @@ -1602,7 +1603,7 @@ requests = ">=2.0.0" [[package]] name = "notifications-utils" -version = "0.4.5" +version = "0.4.6" description = "" optional = false python-versions = "^3.12.2" @@ -1613,8 +1614,8 @@ develop = false async-timeout = "^4.0.2" bleach = "^6.1.0" blinker = "^1.6.2" -boto3 = "^1.34.77" -botocore = "^1.34.79" +boto3 = "^1.34.83" +botocore = "^1.34.83" cachetools = "^5.3.0" certifi = "^2024.2.2" cffi = "^1.16.0" @@ -1625,7 +1626,7 @@ flask = "^2.3.2" flask-redis = "^0.4.0" geojson = "^3.0.1" govuk-bank-holidays = "^0.14" -idna = "^3.6" +idna = "^3.7" itsdangerous = "^2.1.2" jinja2 = "^3.1.3" jmespath = "^1.0.1" @@ -1654,7 +1655,7 @@ werkzeug = "^3.0.1" type = "git" url = "https://github.com/GSA/notifications-utils.git" reference = "HEAD" -resolved_reference = "7d1d2e9bb3791316231e97433c71da6a70c4d2ab" +resolved_reference = "d0db6073406bd160d2007edb9d00e41c9d5d44b7" [[package]] name = "numpy" diff --git a/tests/app/main/forms/test_placeholder_form.py b/tests/app/main/forms/test_placeholder_form.py index 40a12ae56..01075472f 100644 --- a/tests/app/main/forms/test_placeholder_form.py +++ b/tests/app/main/forms/test_placeholder_form.py @@ -48,7 +48,6 @@ def test_form_class_not_mutated(notify_admin): (False, "phone number", "sms", "2028675309", None), (False, "phone number", "sms", "+1 (202) 867-5309", None), (True, "phone number", "sms", "+123", "Not enough digits"), - (True, "phone number", "sms", "+44(0)7900 900-123", None), (True, "phone number", "sms", "+1-2345-678890", None), (False, "anything else", "sms", "", "Cannot be empty"), (False, "anything else", "email", "", "Cannot be empty"), diff --git a/tests/app/main/views/test_dashboard.py b/tests/app/main/views/test_dashboard.py index 4c86fb328..c4326ef89 100644 --- a/tests/app/main/views/test_dashboard.py +++ b/tests/app/main/views/test_dashboard.py @@ -354,10 +354,9 @@ def test_inbound_messages_shows_count_of_messages_when_there_are_no_messages( "(202) 867-5300 message-2 1 hour ago", "(202) 867-5300 message-3 1 hour ago", "(202) 867-5302 message-4 3 hours ago", - "+33 1 12 34 56 78 message-5 5 hours ago", + "+33(0)1 12345678 message-5 5 hours ago", "(202) 555-0104 message-6 7 hours ago", "(202) 555-0104 message-7 9 hours ago", - "+682 12345 message-8 9 hours ago", ] ), ) @@ -519,10 +518,10 @@ def test_download_inbox( "(202) 867-5300,message-2,07-01-2016 10:59 US/Eastern\r\n" "(202) 867-5300,message-3,07-01-2016 10:59 US/Eastern\r\n" "(202) 867-5302,message-4,07-01-2016 08:59 US/Eastern\r\n" - "+33 1 12 34 56 78,message-5,07-01-2016 06:59 US/Eastern\r\n" + "+33(0)1 12345678,message-5,07-01-2016 06:59 US/Eastern\r\n" "(202) 555-0104,message-6,07-01-2016 04:59 US/Eastern\r\n" "(202) 555-0104,message-7,07-01-2016 02:59 US/Eastern\r\n" - "+682 12345,message-8,07-01-2016 02:59 US/Eastern\r\n" + "+68212345,message-8,07-01-2016 02:59 US/Eastern\r\n" ) diff --git a/tests/app/main/views/test_send.py b/tests/app/main/views/test_send.py index e15772833..ab35dd29b 100644 --- a/tests/app/main/views/test_send.py +++ b/tests/app/main/views/test_send.py @@ -979,7 +979,7 @@ def test_upload_valid_csv_shows_preview_and_table( '
A
', ( ' ' - '
' + "
" "
    " "
  • foo
  • foo
  • foo
  • " "
" @@ -992,7 +992,7 @@ def test_upload_valid_csv_shows_preview_and_table( '
B
', ( ' ' - '
' + "
" "
    " "
  • foo
  • foo
  • foo
  • " "
" @@ -1005,7 +1005,7 @@ def test_upload_valid_csv_shows_preview_and_table( '
C
', ( ' ' - '
' + "
" "
    " "
  • foo
  • foo
  • " "
" diff --git a/tests/end_to_end/test_create_new_template.py b/tests/end_to_end/test_create_new_template.py new file mode 100644 index 000000000..293931785 --- /dev/null +++ b/tests/end_to_end/test_create_new_template.py @@ -0,0 +1,188 @@ +import datetime +import os +import re +import uuid + +from playwright.sync_api import expect + +E2E_TEST_URI = os.getenv("NOTIFY_E2E_TEST_URI") + + +def _setup(page): + # Prepare for adding a new service later in the test. + current_date_time = datetime.datetime.now() + new_service_name = "E2E Federal Test Service {now} - {browser_type}".format( + now=current_date_time.strftime("%m/%d/%Y %H:%M:%S"), + browser_type=page.context.browser.browser_type.name, + ) + + page.goto(f"{E2E_TEST_URI}/accounts") + + # Check to make sure that we've arrived at the next page. + page.wait_for_load_state("domcontentloaded") + + # Check to make sure that we've arrived at the next page. + # Check the page title exists and matches what we expect. + expect(page).to_have_title(re.compile("Choose service")) + + # Check for the sign in heading. + sign_in_heading = page.get_by_role("heading", name="Choose service") + expect(sign_in_heading).to_be_visible() + + # Retrieve some prominent elements on the page for testing. + add_service_button = page.get_by_role( + "button", name=re.compile("Add a new service") + ) + + expect(add_service_button).to_be_visible() + + existing_service_link = page.get_by_role("link", name=new_service_name) + + # Check to see if the service was already created - if so, we should fail. + # TODO: Figure out how to make this truly isolated, and/or work in a + # delete service workflow. + expect(existing_service_link).to_have_count(0) + + # Click on add a new service. + add_service_button.click() + + # Check to make sure that we've arrived at the next page. + page.wait_for_load_state("domcontentloaded") + + # Check for the sign in heading. + about_heading = page.get_by_role("heading", name="About your service") + expect(about_heading).to_be_visible() + + # Retrieve some prominent elements on the page for testing. + service_name_input = page.locator('xpath=//input[@name="name"]') + add_service_button = page.get_by_role("button", name=re.compile("Add service")) + + expect(service_name_input).to_be_visible() + expect(add_service_button).to_be_visible() + + # Fill in the form. + service_name_input.fill(new_service_name) + + # Click on add service. + add_service_button.click() + + # Check to make sure that we've arrived at the next page. + page.wait_for_load_state("domcontentloaded") + + # Check for the service name title and heading. + service_heading = page.get_by_text(new_service_name, exact=True) + + expect(service_heading).to_be_visible() + expect(page).to_have_title(re.compile(new_service_name)) + + return new_service_name + + +def create_new_template(page): + + current_service_link = page.get_by_text("Current service") + expect(current_service_link).to_be_visible() + current_service_link.click() + + # Check to make sure that we've arrived at the next page. + page.wait_for_load_state("domcontentloaded") + + send_messages_button = page.get_by_role("link", name="Send messages") + expect(send_messages_button).to_be_visible() + send_messages_button.click() + + # Check to make sure that we've arrived at the next page. + page.wait_for_load_state("domcontentloaded") + + create_template_button = page.get_by_role("button", name="New template") + expect(create_template_button).to_be_visible() + create_template_button.click() + + # Check to make sure that we've arrived at the next page. + page.wait_for_load_state("domcontentloaded") + + start_with_a_blank_template_radio = page.get_by_text("Start with a blank template") + expect(start_with_a_blank_template_radio).to_be_visible() + start_with_a_blank_template_radio.click() + + continue_button = page.get_by_role("button", name="Continue") + + # continue_button = page.get_by_text("Continue") + expect(continue_button).to_be_visible() + continue_button.click() + + # Check to make sure that we've arrived at the next page. + page.wait_for_load_state("domcontentloaded") + + template_name_input = page.get_by_text("Template name") + expect(template_name_input).to_be_visible() + template_name = str(uuid.uuid4()) + template_name_input.fill(template_name) + message_input = page.get_by_role("textbox", name="Message") + expect(message_input).to_be_visible() + message = "Test message for e2e test" + message_input.fill(message) + + save_button = page.get_by_text("Save") + expect(save_button).to_be_visible() + save_button.click() + + # Check to make sure that we've arrived at the next page. + page.wait_for_load_state("domcontentloaded") + + use_this_template_button = page.get_by_text("Use this template") + expect(use_this_template_button).to_be_visible() + use_this_template_button.click() + + # Check to make sure that we've arrived at the next page. + page.wait_for_load_state("domcontentloaded") + + use_my_phone_number_link = page.get_by_text("Use my phone number") + expect(use_my_phone_number_link).to_be_visible() + use_my_phone_number_link.click() + + # Check to make sure that we've arrived at the next page. + page.wait_for_load_state("domcontentloaded") + + preview_button = page.get_by_text("Preview") + expect(preview_button).to_be_visible() + preview_button.click() + + # Check to make sure that we've arrived at the next page. + page.wait_for_load_state("domcontentloaded") + + # We are not going to send the message for this test, we just want to confirm + # that the template has been created and we are now seeing the message from the + # template in the preview. + assert "Test message for e2e test" in page.content() + + +def test_create_new_template(authenticated_page): + page = authenticated_page + + _setup(page) + + create_new_template(page) + + _teardown(page) + + +def _teardown(page): + page.click("text='Settings'") + + # Check to make sure that we've arrived at the next page. + page.wait_for_load_state("domcontentloaded") + + page.click("text='Delete this service'") + + # Check to make sure that we've arrived at the next page. + page.wait_for_load_state("domcontentloaded") + + page.click("text='Yes, delete'") + + # Check to make sure that we've arrived at the next page. + page.wait_for_load_state("domcontentloaded") + + # Check to make sure that we've arrived at the next page. + # Check the page title exists and matches what we expect. + expect(page).to_have_title(re.compile("Choose service")) diff --git a/tests/end_to_end/test_send_message_from_existing_template.py b/tests/end_to_end/test_send_message_from_existing_template.py new file mode 100644 index 000000000..535ad6c5f --- /dev/null +++ b/tests/end_to_end/test_send_message_from_existing_template.py @@ -0,0 +1,339 @@ +import datetime +import os +import re +import uuid + +from playwright.sync_api import expect + +E2E_TEST_URI = os.getenv("NOTIFY_E2E_TEST_URI") + + +def _setup(page): + # Prepare for adding a new service later in the test. + current_date_time = datetime.datetime.now() + new_service_name = "E2E Federal Test Service {now} - {browser_type}".format( + now=current_date_time.strftime("%m/%d/%Y %H:%M:%S"), + browser_type=page.context.browser.browser_type.name, + ) + + page.goto(f"{E2E_TEST_URI}/accounts") + + # Check to make sure that we've arrived at the next page. + page.wait_for_load_state("domcontentloaded") + + # Check to make sure that we've arrived at the next page. + # Check the page title exists and matches what we expect. + expect(page).to_have_title(re.compile("Choose service")) + + # Check for the sign in heading. + sign_in_heading = page.get_by_role("heading", name="Choose service") + expect(sign_in_heading).to_be_visible() + + # Retrieve some prominent elements on the page for testing. + add_service_button = page.get_by_role( + "button", name=re.compile("Add a new service") + ) + + expect(add_service_button).to_be_visible() + + existing_service_link = page.get_by_role("link", name=new_service_name) + + # Check to see if the service was already created - if so, we should fail. + # TODO: Figure out how to make this truly isolated, and/or work in a + # delete service workflow. + expect(existing_service_link).to_have_count(0) + + # Click on add a new service. + add_service_button.click() + + # Check to make sure that we've arrived at the next page. + page.wait_for_load_state("domcontentloaded") + + # Check for the sign in heading. + about_heading = page.get_by_role("heading", name="About your service") + expect(about_heading).to_be_visible() + + # Retrieve some prominent elements on the page for testing. + service_name_input = page.locator('xpath=//input[@name="name"]') + add_service_button = page.get_by_role("button", name=re.compile("Add service")) + + expect(service_name_input).to_be_visible() + expect(add_service_button).to_be_visible() + + # Fill in the form. + service_name_input.fill(new_service_name) + + # Click on add service. + add_service_button.click() + + # Check to make sure that we've arrived at the next page. + page.wait_for_load_state("domcontentloaded") + + # Check for the service name title and heading. + service_heading = page.get_by_text(new_service_name, exact=True) + + expect(service_heading).to_be_visible() + expect(page).to_have_title(re.compile(new_service_name)) + + return new_service_name + + +def handle_no_existing_template_case(page): + """ + This is the test path for local development. Developers have their own + databases and typically they have service or two already created, which + means they won't see the "Example text message template" which is done + as an introduction. Instead they will see "Create your first template". + Note that this test goes all the way through sending and downloading the + report and verifying the phone number, etc. in the report. This is because + developer systems are fully connected to AWS. + """ + + create_template_button = page.get_by_text("Create your first template") + expect(create_template_button).to_be_visible() + create_template_button.click() + + # Check to make sure that we've arrived at the next page. + page.wait_for_load_state("domcontentloaded") + + new_template_button = page.get_by_text("New template") + expect(new_template_button).to_be_visible() + new_template_button.click() + + # Check to make sure that we've arrived at the next page. + page.wait_for_load_state("domcontentloaded") + + start_with_a_blank_template_radio = page.get_by_text("Start with a blank template") + expect(start_with_a_blank_template_radio).to_be_visible() + start_with_a_blank_template_radio.click() + + continue_button = page.get_by_role("button", name="Continue") + + # continue_button = page.get_by_text("Continue") + expect(continue_button).to_be_visible() + continue_button.click() + + # Check to make sure that we've arrived at the next page. + page.wait_for_load_state("domcontentloaded") + + template_name_input = page.get_by_text("Template name") + expect(template_name_input).to_be_visible() + template_name = str(uuid.uuid4()) + template_name_input.fill(template_name) + message_input = page.get_by_role("textbox", name="Message") + expect(message_input).to_be_visible() + message = "Test message" + message_input.fill(message) + + save_button = page.get_by_text("Save") + expect(save_button).to_be_visible() + save_button.click() + + # Check to make sure that we've arrived at the next page. + page.wait_for_load_state("domcontentloaded") + + use_this_template_button = page.get_by_text("Use this template") + expect(use_this_template_button).to_be_visible() + use_this_template_button.click() + + # Check to make sure that we've arrived at the next page. + page.wait_for_load_state("domcontentloaded") + + use_my_phone_number_link = page.get_by_text("Use my phone number") + expect(use_my_phone_number_link).to_be_visible() + use_my_phone_number_link.click() + + # Check to make sure that we've arrived at the next page. + page.wait_for_load_state("domcontentloaded") + + preview_button = page.get_by_text("Preview") + expect(preview_button).to_be_visible() + preview_button.click() + + # Check to make sure that we've arrived at the next page. + page.wait_for_load_state("domcontentloaded") + + send_button = page.get_by_role("button", name="Send") + expect(send_button).to_be_visible() + send_button.click() + + # Check to make sure that we've arrived at the next page. + page.wait_for_load_state("domcontentloaded") + + dashboard_button = page.get_by_text("Dashboard") + expect(dashboard_button).to_be_visible() + dashboard_button.click() + + # Check to make sure that we've arrived at the next page. + page.wait_for_load_state("domcontentloaded") + + download_link = page.get_by_text("Download") + expect(download_link).to_be_visible() + + # Start waiting for the download + with page.expect_download() as download_info: + # Perform the action that initiates download + download_link.click() + download = download_info.value + # Wait for the download process to complete and save the downloaded file somewhere + download.save_as("download_test_file") + f = open("download_test_file", "r") + + content = f.read() + f.close() + # We don't want to wait 5 minutes to get a response from AWS about the message we sent + # So we are using this invalid phone number the e2e_test_user signed up with (12025555555) + # to shortcircuit the sending process. Our phone number validator will insta-fail the + # message and it won't be sent, but the report will still be generated, which is all + # we care about here. + assert ( + "Phone Number,Template,Sent by,Batch File,Carrier Response,Status,Time" + in content + ) + assert "12025555555" in content + assert "one-off-e2e_test_user" in content + os.remove("download_test_file") + + +def handle_existing_template_case(page): + """ + This is the test path used in the Github action. Right now the e2e tests + are not connected to AWS, so we can't actually send messages. Hence, the + test stops when it verifies that the 'Send' button exists on the page. In + the future we would like to change the way e2e tests run so they run against + staging, in which case either the code in this method can be uncommented, + or perhaps this path will be unnecessary (because staging is not completely clean) + and we will just use the same path as for local development. + """ + existing_template_link = page.get_by_text("Example text message template") + expect(existing_template_link).to_be_visible() + existing_template_link.click() + + # Check to make sure that we've arrived at the next page. + page.wait_for_load_state("domcontentloaded") + + continue_button = page.get_by_role("button", name="Continue") + + # continue_button = page.get_by_text("Continue") + expect(continue_button).to_be_visible() + continue_button.click() + + # Check to make sure that we've arrived at the next page. + page.wait_for_load_state("domcontentloaded") + + continue_button = page.get_by_role("button", name="Continue") + + # continue_button = page.get_by_text("Continue") + expect(continue_button).to_be_visible() + continue_button.click() + + # Check to make sure that we've arrived at the next page. + page.wait_for_load_state("domcontentloaded") + + # day_of_week_input = page.locator('xpath=//input[@name="day of week"]') + # day_of_week_input = page.get_by_text("day of week") + day_of_week_input = page.get_by_role("textbox", name="day of week") + expect(day_of_week_input).to_be_visible() + day_of_week_input.fill("Monday") + + continue_button = page.get_by_role("button", name="Continue") + + # continue_button = page.get_by_text("Continue") + expect(continue_button).to_be_visible() + continue_button.click() + + # Check to make sure that we've arrived at the next page. + page.wait_for_load_state("domcontentloaded") + + color_input = page.get_by_role("textbox", name="color") + expect(color_input).to_be_visible() + color_input.fill("Green") + + # continue_button = page.get_by_text("Continue") + expect(continue_button).to_be_visible() + continue_button.click() + + # Check to make sure that we've arrived at the next page. + page.wait_for_load_state("domcontentloaded") + + preview_button = page.get_by_text("Preview") + expect(preview_button).to_be_visible() + preview_button.click() + + # Check to make sure that we've arrived at the next page. + page.wait_for_load_state("domcontentloaded") + + send_button = page.get_by_role("button", name="Send") + expect(send_button).to_be_visible() + # send_button.click() + + # Check to make sure that we've arrived at the next page. + # page.wait_for_load_state("domcontentloaded") + + # dashboard_button = page.get_by_text("Dashboard") + # expect(dashboard_button).to_be_visible() + # dashboard_button.click() + + # Check to make sure that we've arrived at the next page. + # page.wait_for_load_state("domcontentloaded") + + # download_link = page.get_by_text("Download") + # expect(download_link).to_be_visible() + + # Start waiting for the download + # with page.expect_download() as download_info: + # Perform the action that initiates download + # download_link.click() + # download = download_info.value + # Wait for the download process to complete and save the downloaded file somewhere + # download.save_as("download_test_file") + # f = open("download_test_file", "r") + + # content = f.read() + # f.close() + # We don't want to wait 5 minutes to get a response from AWS about the message we sent + # So we are using this invalid phone number the e2e_test_user signed up with (12025555555) + # to shortcircuit the sending process. Our phone number validator will insta-fail the + # message and it won't be sent, but the report will still be generated, which is all + # we care about here. + # assert ( + # "Phone Number,Template,Sent by,Batch File,Carrier Response,Status,Time" + # in content + # ) + # assert "12025555555" in content + # assert "one-off-e2e_test_user" in content + # os.remove("download_test_file") + + +def test_send_message_from_existing_template(authenticated_page): + page = authenticated_page + + _setup(page) + + if page.get_by_text("Create your first template").count() > 0: + handle_no_existing_template_case(page) + else: + handle_existing_template_case(page) + + _teardown(page) + + +def _teardown(page): + page.click("text='Settings'") + + # Check to make sure that we've arrived at the next page. + page.wait_for_load_state("domcontentloaded") + + page.click("text='Delete this service'") + + # Check to make sure that we've arrived at the next page. + page.wait_for_load_state("domcontentloaded") + + page.click("text='Yes, delete'") + + # Check to make sure that we've arrived at the next page. + page.wait_for_load_state("domcontentloaded") + + # Check to make sure that we've arrived at the next page. + # Check the page title exists and matches what we expect. + expect(page).to_have_title(re.compile("Choose service"))