def test_name_wildcard_input(self): generated = list( self.query_generator.generate(SearchDirectoryInput(name="foo*")) ) assert len(generated) == 2 assert generated[0].description == 'Name matches "foo*"' assert generated[1].description == 'Name includes "foo*"'
def search_listing( request: Request, service: DirectorySearchService, logger: Logger, session: LocalProxy, settings: ApplicationConfig, ): context = RenderingContext.construct( uwnetid=session.get("uwnetid"), show_experimental=settings.show_experimental, ) try: form_input = SearchDirectoryFormInput.parse_obj(request.form) context.request_input = form_input request_input = SearchDirectoryInput.from_form_input(form_input) context.search_result = service.search_directory(request_input) except Exception as e: logger.exception(str(e)) SearchBlueprint.handle_search_exception(e, context) finally: return ( render_template( "views/search_results.html", **context.dict(exclude_none=True) ), context.status_code, )
def test_phone_input_invalid_number(self): request_input = SearchDirectoryInput(phone="abcdefg") assert request_input.phone assert not request_input.sanitized_phone queries = list(self.query_generator.generate(request_input)) assert not queries
def test_output_includes_department(self, person: str, expected_departments): self.session["uwnetid"] = "foo" input_population = None expected_populations = [] if "student" in person: input_population = PopulationType.students expected_populations.append(input_population) if "employee" in person: if input_population: input_population = PopulationType.all else: input_population = PopulationType.employees expected_populations.append(PopulationType.employees) input_population = PopulationType(input_population) person = getattr(self.mock_people, person) self.list_persons_output["Persons"] = [person.dict(by_alias=True)] request_input = SearchDirectoryInput( name="Lovelace", population=input_population ) output = self.client.search_directory(request_input) assert output.scenarios, output.dict() for population in expected_populations: assert ( output.scenarios[0].populations[population.value].people[0].departments == expected_departments )
def test_output_includes_box_number(self): person = self.mock_people.published_employee self.list_persons_output["Persons"] = [person.dict(by_alias=True)] output = self.client.search_directory( SearchDirectoryInput(email="*blah", population=PopulationType.employees) ) output_person: Person = output.scenarios[0].populations["employees"].people[0] assert output_person.box_number == "351234"
def test_search_directory_happy(self): request_input = SearchDirectoryInput( name="lovelace", population=PopulationType.employees ) output = self.client.search_directory(request_input) assert output.num_results assert output.scenarios assert output.scenarios[0].populations["employees"].people
def test_department_ignores_invalid_data(self): person = self.mock_people.published_employee person.affiliations.employee.directory_listing.positions[0].department = None self.list_persons_output["Persons"] = [person.dict(by_alias=True)] request_input = SearchDirectoryInput( name="lovelace", population=PopulationType.employees ) output = self.client.search_directory(request_input) output_person: Person = output.scenarios[0].populations["employees"].people[0] assert not output_person.departments
def test_search_methods(self): assert SearchDirectoryInput.search_methods() == [ # Yes, this does mean we need to manually update the test if # these fields ever change in any way. This prevents us from # unintended changes that may impact the user experience. "name", "department", "email", "box_number", "phone", ]
def test_search_removes_duplicates(self, search_type): orig = self.mock_people.as_search_output(self.mock_people.published_employee) dupe = orig.copy() orig["Next"] = {"Href": "foo"} self.set_list_persons_output(orig) self.set_get_next_output(ListPersonsOutput.parse_obj(dupe)) request_input = SearchDirectoryInput( name="ada", search_type=search_type, population=PopulationType.all ) output = self.client.search_directory(request_input) # But we should only expect a single result because it was de-duplicated assert output.num_results == 1
def test_query_student_population(self, authenticate: bool): if authenticate: self.session["uwnetid"] = "foo" request_input = SearchDirectoryInput( email="*whatever", population=PopulationType.students ) queries = list(self.query_generator.generate(request_input)) if authenticate: assert len(queries) == 1 assert queries[0].request_input.employee_affiliation_state is None assert queries[0].request_input.student_affiliation_state == "current" else: assert not queries
def test_query_all_populations(self, authenticate: bool): """Ensures that, depending on the population and the authentication context, the correct queries are emitted. """ if authenticate: self.session["uwnetid"] = "foo" request_input = SearchDirectoryInput( email="*whatever", population=PopulationType.all ) queries = list(self.query_generator.generate(request_input)) assert queries[0].request_input.employee_affiliation_state == "current" assert queries[0].request_input.student_affiliation_state is None if authenticate: assert queries[1].request_input.student_affiliation_state == "current" assert queries[1].request_input.employee_affiliation_state is None
def test_output_includes_phones(self): person = self.mock_people.contactable_person self.list_persons_output["Persons"] = [person.dict(by_alias=True)] self.session["uwnetid"] = "foo" request_input = SearchDirectoryInput( name="lovelace", population=PopulationType.all ) output = self.client.search_directory(request_input) contacts = output.scenarios[0].populations["employees"].people[0].phone_contacts assert contacts == dict( phones=["2068675309 Ext. 4242", "19999674222"], faxes=["+1 999 214-9864"], voice_mails=["1800MYVOICE"], touch_dials=["+19999499911"], mobiles=["+1 999 (967)-4222", "+1 999 (967) 4999"], pagers=["1234567"], )
def test_validate_box_number_valid(self, box_number: str): assert SearchDirectoryInput(box_number=box_number).box_number == box_number
def test_validate_email_success(self, email): assert SearchDirectoryInput(email=email).email == email # email?
def search_directory_experimental( self, request_input: SearchDirectoryInput) -> SearchDirectoryOutput: """ This new query function improves performance significantly, but is still being tested for accuracy and edge cases. This only executes one query to PWS per population requested. The query includes wildcards for each token the user input. For example: "buffy anne summers" would become a query for display names matching: "*buffy* *summers*" In this example, PWS would return any of the following results: - buffy anne summers - buffy "the vampire slayer" summers - ubuffya alsummersia - buffy-anne summers - buffy anne summers-finn After the results have been filtered, they are sent to the NameSearchResultReducer, which is responsible for sorting these names into appropriate buckets by relevance. """ timer_context = { "query": request_input.dict( exclude_none=True, by_alias=True, exclude_properties=True, exclude_unset=True, ), "statistics": {}, } timer = Timer("search_directory", context=timer_context).start() statistics = ListPersonsRequestStatistics( num_queries_generated=1, num_user_search_tokens=len(request_input.name.split()), ) query = " ".join(f"*{token}*" for token in request_input.name.split()) results = {} for population in request_input.requested_populations: pws_output: ListPersonsOutput = self._pws.list_persons( ListPersonsInput( display_name=query, employee_affiliation_state=(AffiliationState.current if population == "employees" else None), student_affiliation_state=(AffiliationState.current if population == "students" else None), ), populations=request_input.requested_populations, ) statistics.aggregate(pws_output.request_statistics) results = self.reducer.reduce_output(pws_output, request_input.name, results) while pws_output.next: pws_output = self._pws.get_explicit_href( pws_output.next.href, output_type=ListPersonsOutput) results = self.reducer.reduce_output(pws_output, request_input.name, results) statistics.aggregate(pws_output.request_statistics) statistics.num_duplicates_found = self.reducer.duplicate_hit_count timer.context["statistics"] = statistics.dict(by_alias=True) timer.stop(emit_log=True) return SearchDirectoryOutput(scenarios=[ DirectoryQueryScenarioOutput( description=b.description, populations=self.pws_translator.translate_bucket(b), ) for b in results.values() ])
def search_directory_classic( self, request_input: SearchDirectoryInput) -> SearchDirectoryOutput: timer_context = { "query": request_input.dict( exclude_none=True, by_alias=True, exclude_properties=True, exclude_unset=True, ), "statistics": {}, } duplicate_netids = set() timer = Timer("search_directory", context=timer_context).start() statistics = ListPersonsRequestStatistics() scenarios: List[DirectoryQueryScenarioOutput] = [] scenario_description_indexes: Dict[str, int] = {} for generated in self.query_generator.generate(request_input): self.logger.debug( f"Querying: {generated.description} with " f"{generated.request_input.dict(exclude_unset=True, exclude_defaults=True)}" ) statistics.num_queries_generated += 1 pws_output: ListPersonsOutput = self._pws.list_persons( generated.request_input, populations=request_input.requested_populations) aggregate_output = pws_output statistics.aggregate(pws_output.request_statistics) while pws_output.next and pws_output.next.href: pws_output = self._pws.get_explicit_href(pws_output.next.href) statistics.aggregate(pws_output.request_statistics) aggregate_output.persons.extend(pws_output.persons) populations = self.pws_translator.translate_scenario( aggregate_output, duplicate_netids) statistics.num_duplicates_found += populations.pop( "__META__", {}).get("duplicates", 0) scenario_output = DirectoryQueryScenarioOutput( description=generated.description, populations=populations, ) if generated.description in scenario_description_indexes: index = scenario_description_indexes[generated.description] existing_scenario = scenarios[index] for population, results in scenario_output.populations.items(): if population not in existing_scenario.populations: existing_scenario.populations[population].people = [] existing_scenario.populations[population].people.extend( results.people) else: scenarios.append(scenario_output) scenario_description_indexes[ generated.description] = len(scenarios) - 1 timer.context["statistics"] = statistics.dict(by_alias=True) timer.stop(emit_log=True) return SearchDirectoryOutput(scenarios=scenarios)
def test_email_input(self, input_value, expected_snippets): request_input = SearchDirectoryInput(email=input_value) queries = list(self.query_generator.generate(request_input)) assert len(queries) == len(expected_snippets) for i, snippet in enumerate(expected_snippets): assert snippet in queries[i].description
def test_box_number_input(self): request_input = SearchDirectoryInput(box_number="123456") queries = list(self.query_generator.generate(request_input)) assert len(queries) == 2 assert queries[0].request_input.mail_stop == "123456" assert queries[1].request_input.mail_stop == "35123456"
def test_validate_email_failure(self, email): with pytest.raises(ValidationError): SearchDirectoryInput(email="@uw")
def test_phone_input_long_number(self): request_input = SearchDirectoryInput(phone="+1 (206) 555-4321") queries = list(self.query_generator.generate(request_input)) assert queries[0].request_input.phone_number == "12065554321" assert queries[1].request_input.phone_number == "2065554321" assert len(queries) == 2
def test_validate_box_number_invalid(self, box_number: str): with pytest.raises(ValidationError): SearchDirectoryInput(box_number=box_number)
def test_requested_populations(self, input_population, expected): assert ( SearchDirectoryInput(population=input_population).requested_populations == expected )
def test_email_wildcard_input(self): generated = list( self.query_generator.generate(SearchDirectoryInput(email="foo*")) ) assert len(generated) == 1 assert generated[0].description == 'Email matches "foo*"'
class AppInjectorModule(Module): search_attributes = SearchDirectoryInput.search_methods() @provider @request def provide_request_session(self) -> LocalProxy: return cast( LocalProxy, flask_session ) # Cast this so that IDEs knows what's up; does not affect runtime @provider @singleton def provide_logger(self, yaml_loader: YAMLSettingsLoader, injector: Injector) -> logging.Logger: logger_settings = yaml_loader.load_settings("logging") dictConfig(logger_settings) app_logger = logging.getLogger("gunicorn.error").getChild("app") formatter = app_logger.handlers[0].formatter formatter.injector = injector return app_logger def register_jinja_extensions(self, app: Flask): """You can define jinja filters here in order to make them available in our jinja templates.""" @app.template_filter() def titleize(text): """ Turns snake_case and camelCase into "Snake Case" and "Camel Case," respectively. Use: {{ some_string|titleize }} """ return inflection.titleize(text) @app.template_filter() def singularize(text: str): """ Takes something plural and makes it singular. Use: {{ "parrots"|singularize }} """ return inflection.singularize(text) @app.template_filter() def linkify(text: str): """ Replaces all non alphanum characters with '-' and lowercases everything. "foo: bar baz st. claire" => "foo-bar-baz-st-claire" use: {{ "Foo Bar Baz St. Claire"|linkify }} """ return inflection.parameterize(text) @app.template_filter() def externalize(text): """ Some values are great for api models but not so great for humans. So, this allows for that extra layer of translation where needed. If this gets more complicated for any reason, this table of internal vs. external values should be moved into its own service, or at least a dict. """ if text == "employees": return "faculty/staff" return text @app.template_test() def blank(val): """ A quick way to test whether a value is undefined OR none. This is an alternative to writing '{% if val is defined and val is not sameas None %}' """ return test_undefined(val) or val is None @app.context_processor def provide_search_attributes(): """Makes the list of search attributes available to the parser without having to hard-code them.""" return {"search_attributes": self.search_attributes} @app.context_processor def provide_current_year(): return {"current_year": datetime.utcnow().year} @provider @singleton def provide_app( self, injector: Injector, app_settings: ApplicationConfig, logger: logging.Logger, # Any blueprints that are depended on here must # get registered below, under "App blueprints get registered here." search_blueprint: SearchBlueprint, app_blueprint: AppBlueprint, saml_blueprint: SAMLBlueprint, mock_saml_blueprint: MockSAMLBlueprint, ) -> Flask: # First we have to do some logging configuration, before the # app instance is created. # We've done our pre-work; now we can create the instance itself. app = Flask("husky_directory") app.jinja_env.trim_blocks = True app.jinja_env.lstrip_blocks = True app.config.update(app_settings.app_configuration) app.url_map.strict_slashes = ( False # Allows both '/search' and '/search/' to work ) if app_settings.auth_settings.use_test_idp: python3_saml.MOCK = True mock.MOCK_LOGIN_URL = "/mock-saml/login" app.register_blueprint(mock_saml_blueprint) # App blueprints get registered here. app.register_blueprint(app_blueprint) app.register_blueprint(search_blueprint) app.register_blueprint(saml_blueprint) # Ensure the application is using the same logger as everything else. app.logger = logger # Bind an injector to the app itself to manage the scopes of # our dependencies appropriate for each request. FlaskInjector(app=app, injector=injector) self._configure_app_session(app, app_settings) self._configure_prometheus(app, app_settings, injector) attach_app_error_handlers(app) self.register_jinja_extensions(app) app.logger.info( f"Application worker started at " f'{datetime.utcnow().astimezone(pytz.timezone("US/Pacific"))}') return app @staticmethod def _configure_prometheus(app: Flask, app_settings: ApplicationConfig, injector: Injector): """ Sets up a prometheus client with authorization and binds it to the Injector using the MetricsClient alias. :param app: :param app_settings: :param injector: :return: """ secrets = app_settings.secrets metrics_auth = HTTPBasicAuth() @metrics_auth.verify_password def verify_credentials(username: str, password: str): if secrets.prometheus_username and secrets.prometheus_password: credentials = ( secrets.prometheus_username.get_secret_value(), secrets.prometheus_password.get_secret_value(), ) return (username, password) == credentials app.logger.warning( "No prometheus authorization is configured. Anyone can scrape these metrics." ) return True # If the environment isn't configured with auth (e.g., testing) metrics = MetricsClient( app, debug=True, metrics_decorator=metrics_auth.login_required, defaults_prefix= f"{app_settings.metrics_settings.metric_prefix}_flask", ) app.metrics = metrics injector.binder.bind(MetricsClient, metrics, scope=singleton) @staticmethod def _configure_app_session(app: Flask, app_settings: ApplicationConfig) -> NoReturn: # There is something wrong with the flask_session implementation that # is supposed to translate flask config values into redis settings; # also, it doesn't support authorization (what?!) so we have to # use their model to explicitly set the interface instead of relying # on the magic. # TODO: It seems like flask_sessions is actually an abandoned project, so it might # be better to just remove it and implement our own session # interface based on their work. But this is fine for now. if app.config["SESSION_TYPE"] == "redis": redis_settings = app_settings.redis_settings app.logger.info( f"Setting up redis cache with settings: {redis_settings.flask_config_values}" ) app.session_interface = RedisSessionInterface( redis=Redis( host=redis_settings.host, port=redis_settings.port, username=redis_settings.namespace, password=redis_settings.password.get_secret_value(), ), key_prefix=redis_settings. flask_config_values["SESSION_KEY_PREFIX"], ) else: Session(app)
def test_sanitized_phone(self): assert ( SearchDirectoryInput(phone="+1 (555) 867-5309").sanitized_phone == "15558675309" )
def test_phone_input_short_number(self): request_input = SearchDirectoryInput(phone="2065554321") queries = list(self.query_generator.generate(request_input)) assert len(queries) == 1
class TestSearchBlueprint(BlueprintSearchTestBase): def test_render_summary_success(self, search_method: str = "name", search_query: str = "lovelace"): response = self.flask_client.post("/", data={ "method": search_method, "query": search_query }) assert response.status_code == 200 profile = self.mock_people.contactable_person with self.html_validator.validate_response(response) as html: with self.html_validator.scope("table", summary="results"): self.html_validator.assert_has_tag_with_text( "td", profile.affiliations.employee.directory_listing.phones[0], ) self.html_validator.assert_has_tag_with_text( "td", profile.affiliations.employee.directory_listing.emails[0], ) assert "autofocus" not in html.find("input", attrs={ "name": "query" }).attrs def _set_up_multipage_search(self): page_one = self.mock_people.as_search_output(next_=ListPersonsInput( href="https://foo/page-2")) page_two = self.mock_send_request.return_value self.mock_send_request.return_value = page_one mock_next_page = mock.patch.object(self.pws_client, "get_explicit_href").start() mock_next_page.return_value = ListPersonsOutput.parse_obj(page_two) def test_render_multi_page_experimental_search(self): self._set_up_multipage_search() self.test_render_summary_success() def test_render_multi_page_classic_search(self): self._set_up_multipage_search() self.test_render_summary_success(search_method="email", search_query="foo") def test_copyright_footer(self): response = self.flask_client.get("/") assert response.status_code == 200 current_year = datetime.utcnow().year html: BeautifulSoup with self.html_validator.validate_response(response) as html: element = html.find("p", attrs={"id": "footer-copyright"}) assert f"© {current_year}" in element.text @pytest.mark.parametrize("log_in", (True, False)) def test_render_full_success(self, log_in): if log_in: self.flask_client.get("/saml/login", follow_redirects=True) assert self.session.get("uwnetid") response = self.flask_client.post( "/", data={ "query": "lovelace", "method": "name", "length": "full", "population": "all", }, ) profile = self.mock_people.contactable_person with self.html_validator.validate_response(response): self.html_validator.assert_has_tag_with_text( "h4", profile.display_name) self.html_validator.assert_has_scenario_anchor( "employees-last-name-is-lovelace") if log_in: self.html_validator.assert_has_scenario_anchor( "students-last-name-is-lovelace") with self.html_validator.scope("ul", class_="dir-listing"): self.html_validator.assert_has_tag_with_text( "li", profile.affiliations.employee.directory_listing.emails[0]) self.html_validator.assert_has_tag_with_text( "li", profile.affiliations.employee.directory_listing.phones[0]) self.html_validator.assert_has_tag_with_text( "li", str(profile.affiliations.employee.mail_stop)) def test_render_student_no_phone(self): self.flask_client.get("/saml/login", follow_redirects=True) profile = self.mock_people.published_student profile.affiliations.student.directory_listing.phone = None profile.affiliations.employee = None self.mock_send_request.return_value = self.mock_people.as_search_output( profile) response = self.flask_client.post( "/", data={ "query": "lovelace", "method": "name", "length": "full", "population": "all", }, ) with self.html_validator.validate_response(response): assert response.status_code == 200 self.html_validator.assert_not_has_tag_with_text("li", "Phone:") def test_render_no_results(self): self.mock_send_request.return_value = self.mock_people.as_search_output( ) response = self.flask_client.post("/", data={"query": "lovelace"}) with self.html_validator.validate_response(response) as html: self.html_validator.assert_not_has_scenario_anchor( "employees-name-matches-lovelace") self.html_validator.assert_not_has_scenario_anchor( "students-name-matches-lovelace") assert not html.find("table", summary="results") assert html.find(string=re.compile("No matches for")) self.html_validator.assert_has_tag_with_text( "b", 'Name is "lovelace"') def test_render_invalid_box_number(self): response = self.flask_client.post("/", data={ "query": "abcdef", "method": "box_number" }) assert response.status_code == 400 with self.html_validator.validate_response(response) as html: assert not html.find("table", summary="results") assert html.find(string=re.compile("Encountered error")) self.html_validator.assert_has_tag_with_text( "b", "box number (input can only contain digits)") def test_render_full_no_box_number(self): self.mock_send_request.return_value = self.mock_people.as_search_output( self.mock_people.published_student) self.flask_client.get("/saml/login", follow_redirects=True) with self.html_validator.validate_response( self.flask_client.post( "/", data={ "query": "lovelace", "length": "full", "population": "all", }, )) as html: assert not html.find_all("li", class_="dir-boxstuff") self.html_validator.assert_has_scenario_anchor( "students-last-name-is-lovelace") self.html_validator.assert_not_has_scenario_anchor( "employees-last-name-is-lovelace") with self.html_validator.scope("div", class_="usebar"): self.html_validator.assert_has_submit_button_with_text( "Download vcard") def test_render_unexpected_error(self): self.mock_send_request.side_effect = RuntimeError response = self.flask_client.post("/", data={ "query": "123456", "method": "box_number" }) with self.html_validator.validate_response(response): self.html_validator.assert_has_tag_with_text( "b", "Something unexpected happened") def test_user_login_flow(self): with self.html_validator.validate_response(self.flask_client.get("/")): self.html_validator.assert_not_has_student_search_options() self.html_validator.assert_has_sign_in_link() with self.html_validator.validate_response( self.flask_client.get("/saml/login", follow_redirects=True)): self.html_validator.assert_has_student_search_options() self.html_validator.assert_not_has_sign_in_link() def test_user_stays_logged_in_after_search(self): self.test_user_login_flow() with self.html_validator.validate_response( self.flask_client.post( "/", data={ "query": "lovelace", "method": "name", }, )): self.html_validator.assert_has_student_search_options() self.html_validator.assert_not_has_sign_in_link() def test_user_stays_logged_in_revisit(self): self.test_user_login_flow() with self.html_validator.validate_response(self.flask_client.get("/")): self.html_validator.assert_has_student_search_options() self.html_validator.assert_not_has_sign_in_link() @pytest.mark.parametrize( "search_field, search_value", [ ("name", "bugbear"), ("phone", "abcdefg"), ("department", "UW-IT IAM"), ("box_number", "12345"), ("email", "*****@*****.**"), ], ) def test_render_no_matches(self, search_field, search_value): query_output = self.mock_people.as_search_output() self.mock_send_request.return_value = query_output with self.html_validator.validate_response( self.flask_client.post("/", data={ "method": search_field, "query": search_value })): self.html_validator.assert_has_tag_with_text( "p", f'no matches for {titleize(search_field)} is "{search_value}"') def assert_form_fields_match_expected( self, response, expected: SearchDirectoryFormInput, signed_in: bool, recurse: bool = True, ): """ Given a query response, ensures that the user flow thereafter is as expected; if the query resulted in summary results with a "more" button, this also simulates the button request to ensure that the same state is preserved through the next search request. """ with self.html_validator.validate_response(response) as html: # Someone not signed in who posts a different population will not # have the population options displayed that they selected, # so we skip this check. (No actual user that is using # our website normally will encounter this situation, # but it's technically a possibility.) if signed_in or expected.population == "employees": assert ("checked" in html.find( "input", attrs={ "id": f"population-option-{expected.population}" }, ).attrs) # Ensure that the sign in link is (or is not) visible, based on whether the user # is signed in. self.html_validator.has_sign_in_link( assert_=True, assert_expected_=not signed_in) # Ensure that the form field is filled in with the same information as was input. assert (html.find("input", attrs={ "name": "query" }).attrs.get("value") == expected.query) # Ensure that the result detail option is preserved assert ("checked" in html.find("input", attrs={ "id": f"length-{expected.length}" }).attrs) # Ensure that the search field is selected in the form dropdown assert ("selected" in html.find("option", attrs={ "value": expected.method }).attrs) # We don't always expect results. For our current test ecosystem, # we won't see results if: expect_results = signed_in or expected.population != "students" # If we don't expect results, we do expect a message telling us # that there are no results. if not expect_results: self.html_validator.assert_has_tag_with_text( "p", f'No matches for {titleize(expected.method)} is "{expected.query}"', ) # If we have "More" buttons, we simulate clicking on them to ensure that # the buttons properly set render_ options. elif recurse and expected.length == "summary": # Ensure that the same values are carried into the "More" render more_button = html.find("form", id="more-form-1") assert more_button, str(html) request_input = self._get_request_input_from_more_button( more_button) self.assert_form_fields_match_expected( self.flask_client.post("/", data=request_input.dict()), expected, signed_in=signed_in, recurse=False, ) @staticmethod def _get_request_input_from_more_button( button: BeautifulSoup, ) -> SearchDirectoryFormInput: """This iterates through the hidden input elements that make up our "more form", which is a form masquerading as a button for the time being. The element values are serialized into the same request input that clicking on the button would generate, so that we can validate the correct options were set, and that the server renders those overrides correctly. """ def get_field_value(field): return button.find("input", attrs=dict(name=field)).attrs.get("value") return SearchDirectoryFormInput( population=get_field_value("population"), query=get_field_value("query"), method=get_field_value("method"), length=get_field_value("length"), render_query=get_field_value("render_query"), render_method=get_field_value("render_method"), render_length=get_field_value("render_length"), ) @pytest.mark.parametrize("search_field", SearchDirectoryInput.search_methods()) @pytest.mark.parametrize("population", ("employees", "students", "all")) @pytest.mark.parametrize("sign_in", (True, False)) @pytest.mark.parametrize("result_detail", ("full", "summary")) def test_render_form_option_stickiness(self, search_field, population, sign_in, result_detail): """ This uses combinatoric parametrize calls to run through every combination of options and ensure that, after rendering, everything is rendered as we expect based on the search parameters. This generates tests for every combination of the parametrized fields listed above, so that we can have a great deal of confidence that the page is rendering as expected based on its input. """ query_value = ("lovelace" if search_field not in ("phone", "box_number") else "12345") request = SearchDirectoryFormInput( method=search_field, population=PopulationType(population), length=ResultDetail(result_detail), query=query_value, ) if sign_in: with self.html_validator.validate_response( self.flask_client.get("/saml/login", follow_redirects=True)) as html: self.html_validator.assert_not_has_sign_in_link() assert html.find("label", attrs={"for": "population-option-students"}) response = self.flask_client.post("/", data=request.dict()) self.assert_form_fields_match_expected(response, request, sign_in, recurse=True) def test_get_person_method_not_allowed(self): response = self.flask_client.get("/person/listing") assert response.status_code == 405 @pytest.mark.parametrize("search_term", ["Smith", "Lovelace"]) def test_get_person_non_matching_surname(self, search_term): """Ensures that our name constraints work by setting a preferred last name that is different than the registered last name, and searching for the registered last name. """ profile = self.mock_people.published_employee profile.preferred_last_name = "Smith" profile.display_name = "Ada Smith" expected_num_results = 1 if profile.preferred_last_name == search_term else 0 self.mock_send_request.return_value = self.mock_people.as_search_output( profile) response = self.flask_client.post("/", data={ "method": "name", "query": search_term }) assert response.status_code == 200 with self.html_validator.validate_response(response) as html: assert (len(html.find_all( "tr", class_="summary-row")) == expected_num_results), str(html) def test_list_people_sort(self, random_string): ada_1 = self.mock_people.published_employee.copy( update={ "display_name": "Ada Zlovelace", "preferred_last_name": "Zlovelace", "registered_surname": "Alovelace", "netid": random_string(), }, deep=True, ) ada_1.affiliations.employee.directory_listing.phones = ["222-2222"] ada_2 = self.mock_people.published_employee.copy( update={ "display_name": "Ada Blovelace", "registered_surname": "Blovelace", "netid": random_string(), }, deep=True, ) ada_2.affiliations.employee.directory_listing.phones = ["888-8888"] ada_3 = self.mock_people.published_employee.copy( update={ "display_name": "Ada Alovelace", "preferred_last_name": "Alovelace", "netid": random_string(), }, deep=True, ) ada_3.affiliations.employee.directory_listing.phones = ["999-9999"] people = self.mock_people.as_search_output(ada_1, ada_2, ada_3) self.mock_send_request.return_value = people response = self.flask_client.post( "/", data={ "method": "name", "query": "lovelace" }, ) html = response.data.decode("UTF-8") assert response.status_code == 200 assert html.index("999-9999") < html.index("888-8888") assert html.index("888-8888") < html.index("222-2222") def test_get_person_vcard(self): """ Tests that the blueprint returns the right result, but does not test permutations of vcards; for that see, tests/services/vcard.py """ person = self.mock_people.published_employee href = base64.b64encode("foo".encode("UTF-8")).decode("UTF-8") self.mock_send_request.return_value = person.dict(by_alias=True) response = self.flask_client.post("/person/vcard", data={"person_href": href}) assert response.status_code == 200 assert response.mimetype == "text/vcard" vcard = response.data.decode("UTF-8") assert vcard.startswith("BEGIN:VCARD") assert vcard.endswith("END:VCARD") @pytest.mark.parametrize("succeed", (True, False)) def test_get_person_listing(self, succeed: bool): person = self.mock_people.contactable_person href = base64.b64encode("foo".encode("UTF-8")).decode("UTF-8") if succeed: self.mock_send_request.return_value = person.dict(by_alias=True) else: self.mock_send_request.side_effect = exceptions.NotFound response = self.flask_client.post("/person/listing", data={"person_href": href}) if succeed: assert response.status_code == 200 with self.html_validator.validate_response(response): self.html_validator.assert_has_tag_with_text( "h4", "Ada Lovelace") else: assert response.status_code == 404 with self.html_validator.validate_response(response): self.html_validator.assert_has_tag_with_text( "p", "404 not found")