From 09d012d0656b51f08e160f2d4fbacffb8f782574 Mon Sep 17 00:00:00 2001 From: Kodjo Sossouvi Date: Sun, 26 Oct 2025 22:45:34 +0100 Subject: [PATCH] Working on LoginPage tests --- Makefile | 6 +- src/myfasthtml/auth/routes.py | 5 +- src/myfasthtml/auth/utils.py | 4 +- src/myfasthtml/core/testclient.py | 96 +++- tests/auth/test_login.py | 38 +- tests/testclient/test_mytestclient.py | 732 +++++++++++++++---------- tests/testclient/test_testable_form.py | 339 +++++++++++- 7 files changed, 900 insertions(+), 320 deletions(-) diff --git a/Makefile b/Makefile index d4147ca..19b2637 100644 --- a/Makefile +++ b/Makefile @@ -14,5 +14,9 @@ clean-build: clean-package find . -name "*.pyc" -exec rm -f {} + find . -name "*.pyo" -exec rm -f {} + +clean-tests: + rm -rf tests/.sesskey + rm -rf tests/Users.db + # Alias to clean everything -clean: clean-build \ No newline at end of file +clean: clean-build clean-tests \ No newline at end of file diff --git a/src/myfasthtml/auth/routes.py b/src/myfasthtml/auth/routes.py index 1e19432..31ec983 100644 --- a/src/myfasthtml/auth/routes.py +++ b/src/myfasthtml/auth/routes.py @@ -18,7 +18,7 @@ from ..auth.utils import ( ) -def setup_auth_routes(app, rt, mount_auth_app=True): +def setup_auth_routes(app, rt, mount_auth_app=True, sqlite_db_path="Users.db"): """ Setup all authentication and protected routes. @@ -26,6 +26,7 @@ def setup_auth_routes(app, rt, mount_auth_app=True): app: FastHTML application instance rt: Route decorator from FastHTML mount_auth_app: Whether to mount the auth FastApi API routes + sqlite_db_path: by default, create a new SQLite database at this path """ # ============================================================================ @@ -187,7 +188,7 @@ def setup_auth_routes(app, rt, mount_auth_app=True): def mount_auth_fastapi_api(): # Mount FastAPI auth backend - auth_api = create_auth_app_for_sqlite("Users.db", "jwt-secret-to-change") + auth_api = create_auth_app_for_sqlite(sqlite_db_path, "jwt-secret-to-change") app.mount("/auth", auth_api) if mount_auth_app: diff --git a/src/myfasthtml/auth/utils.py b/src/myfasthtml/auth/utils.py index 45b724e..5567995 100644 --- a/src/myfasthtml/auth/utils.py +++ b/src/myfasthtml/auth/utils.py @@ -27,8 +27,10 @@ DEFAULT_SKIP_PATTERNS = [ r'.*\.css', r'.*\.js', '/login', - '/login2', + '/login-p', '/register', + '/register-p', + '/logout', ] diff --git a/src/myfasthtml/core/testclient.py b/src/myfasthtml/core/testclient.py index bed8113..9d94a0c 100644 --- a/src/myfasthtml/core/testclient.py +++ b/src/myfasthtml/core/testclient.py @@ -2,6 +2,7 @@ import dataclasses import json import uuid from dataclasses import dataclass +from typing import Self from bs4 import BeautifulSoup, Tag from fastcore.xml import FT, to_xml @@ -132,7 +133,10 @@ class TestableElement: def _support_htmx(self): """Check if the element supports HTMX.""" - return self.ft.has_attr('hx_get') or self.ft.has_attr('hx_post') + return ('hx_get' in self.ft.attrs or + 'hx-get' in self.ft.attrs or + 'hx_post' in self.ft.attrs or + 'hx-post' in self.ft.attrs) class TestableForm(TestableElement): @@ -149,7 +153,7 @@ class TestableForm(TestableElement): source: The source HTML string containing a form. """ super().__init__(client, source) - self.form = BeautifulSoup(source, 'html.parser').find('form') + self.form = BeautifulSoup(self.html_fragment, 'html.parser').find('form') self.fields_mapping = {} # link between the input label and the input name self.fields = {} # field name; field value self.select_fields = {} # list of possible options for 'select' input fields @@ -236,7 +240,7 @@ class TestableForm(TestableElement): elif options: self.fields[name] = options[0]['value'] - def fill_form(self, **kwargs): + def fill(self, **kwargs): """ Fill the form with the given data. @@ -244,16 +248,55 @@ class TestableForm(TestableElement): **kwargs: Field names and their values to fill in the form. """ for name, value in kwargs.items(): - self.fields[name] = value + field_name = self.translate(name) + if field_name not in self.fields: + raise ValueError(f"Invalid field name '{name}'.") + self.fields[self.translate(name)] = value def submit(self): """ Submit the form. + This method handles both HTMX-enabled forms and classic HTML form submissions: + - If the form supports HTMX (has hx_post, hx_get, etc.), uses HTMX request + - Otherwise, simulates a classic browser form submission using the form's + action and method attributes + Returns: The response from the form submission. + + Raises: + ValueError: If the form has no action attribute for classic submission. """ - return self._send_htmx_request(data=self.fields) + # Check if the form supports HTMX + if self._support_htmx(): + return self._send_htmx_request(data=self.fields) + + # Classic form submission + action = self.form.get('action') + if not action or action.strip() == '': + raise ValueError( + "The form has no 'action' attribute. " + "Cannot submit a classic form without a target URL." + ) + + method = self.form.get('method', 'post').upper() + + # Prepare headers for classic form submission + headers = { + "Content-Type": "application/x-www-form-urlencoded" + } + + # Send the request via the client + return self.client.send_request( + method=method, + url=action, + headers=headers, + data=self.fields + ) + + def translate(self, field): + return self.fields_mapping.get(field, field) def _update_fields_mapping(self): """ @@ -459,7 +502,7 @@ class MyTestClient: # make sure that the commands are mounted mount_commands(self.app) - def open(self, path: str): + def open(self, path: str) -> Self: """ Open a page and store its content for subsequent assertions. @@ -483,7 +526,9 @@ class MyTestClient: return self def send_request(self, method: str, url: str, headers: dict = None, data=None, json_data=None): - json_data['session'] = self._session + if json_data is not None: + json_data['session'] = self._session + res = self.client.request( method, url, @@ -500,7 +545,7 @@ class MyTestClient: self.set_content(res.text) return self - def should_see(self, text: str): + def should_see(self, text: str) -> Self: """ Assert that the given text is present in the visible page content. @@ -517,6 +562,11 @@ class MyTestClient: AssertionError: If the text is not found in the page content. ValueError: If no page has been opened yet. """ + + def clean_text(txt): + return "\n".join(line for line in txt.splitlines() if line.strip()) + + if self._content is None: raise ValueError( "No page content available. Call open() before should_see()." @@ -527,7 +577,7 @@ class MyTestClient: if text not in visible_text: # Provide a snippet of the actual content for debugging snippet_length = 200 - content_snippet = ( + content_snippet = clean_text( visible_text[:snippet_length] + "..." if len(visible_text) > snippet_length else visible_text @@ -539,7 +589,7 @@ class MyTestClient: return self - def should_not_see(self, text: str): + def should_not_see(self, text: str) -> Self: """ Assert that the given text is NOT present in the visible page content. @@ -582,7 +632,7 @@ class MyTestClient: return self - def find_element(self, selector: str): + def find_element(self, selector: str) -> TestableElement: """ Find a single HTML element using a CSS selector. @@ -621,7 +671,7 @@ class MyTestClient: f"Found {len(results)} elements matching selector '{selector}'. Expected exactly 1." ) - def find_form(self, fields: list = None): + def find_form(self, fields: list = None) -> TestableForm: """ Find a form element in the page content. Can provide title of the fields to ease the search @@ -630,22 +680,29 @@ class MyTestClient: """ if self._content is None: raise ValueError( - "No page content available. Call open() before find_element()." + "No page content available. Call open() before find_form()." ) results = self._soup.select("form") if len(results) == 0: raise AssertionError( - f"No element form found." + f"No form found." ) - # result = _filter(results, fields) + if fields is None: + remaining = [TestableForm(self, form) for form in results] + else: + remaining = [] + for form in results: + testable_form = TestableForm(self, form) + if all(testable_form.translate(field) in testable_form.fields for field in fields): + remaining.append(testable_form) - if len(results) == 1: - return TestableForm(self, results[0]) + if len(remaining) == 1: + return remaining[0] else: raise AssertionError( - f"Found {len(results)} forms (with the specified fields). Expected exactly 1." + f"Found {len(remaining)} forms (with the specified fields). Expected exactly 1." ) def get_content(self) -> str: @@ -657,7 +714,7 @@ class MyTestClient: """ return self._content - def set_content(self, content: str): + def set_content(self, content: str) -> Self: """ Set the HTML content and parse it with BeautifulSoup. @@ -666,6 +723,7 @@ class MyTestClient: """ self._content = content self._soup = BeautifulSoup(content, 'html.parser') + return self @staticmethod def _find_visible_text_element(soup, text: str): diff --git a/tests/auth/test_login.py b/tests/auth/test_login.py index 24898ea..0017f54 100644 --- a/tests/auth/test_login.py +++ b/tests/auth/test_login.py @@ -1,8 +1,10 @@ +import os + import pytest from fasthtml.fastapp import fast_app from myfasthtml.auth.routes import setup_auth_routes -from myfasthtml.auth.utils import create_auth_beforeware +from myfasthtml.auth.utils import create_auth_beforeware, register_user from myfasthtml.core.testclient import MyTestClient @@ -10,7 +12,7 @@ from myfasthtml.core.testclient import MyTestClient def app(): beforeware = create_auth_beforeware() test_app, test_rt = fast_app(before=beforeware) - setup_auth_routes(test_app, test_rt) + setup_auth_routes(test_app, test_rt, mount_auth_app=True, sqlite_db_path="TestUsers.db") return test_app @@ -24,16 +26,34 @@ def user(app): user = MyTestClient(app) return user + +@pytest.fixture(autouse=True) +def cleanup(): + if os.path.exists("TestUsers.db"): + os.remove("TestUsers.db") + + def test_i_can_see_login_page(user): user.open("/login") user.should_see("Sign In") user.should_see("Register here") - + user.find_form(fields=["Email", "Password"]) + + def test_i_cannot_login_with_wrong_credentials(user): user.open("/login") - user.fill_form({ - "username": "wrong", - "password": "" - }) - user.click("button") - user.should_see("Invalid credentials") \ No newline at end of file + form = user.find_form(fields=["Email", "Password"]) + form.fill(Email="user@email.com", Password="#Passw0rd") + form.submit() + user.should_see("Invalid email or password. Please try again.") + + +def test_i_can_login_with_correct_credentials(user): + # create user + register_user("user@email.com", "user", "#Passw0rd") + + user.open("/login") + form = user.find_form(fields=["Email", "Password"]) + form.fill(Email="user@email.com", Password="#Passw0rd") + form.submit() + user.should_see("You are now logged in") diff --git a/tests/testclient/test_mytestclient.py b/tests/testclient/test_mytestclient.py index 1815a48..19a4707 100644 --- a/tests/testclient/test_mytestclient.py +++ b/tests/testclient/test_mytestclient.py @@ -2,43 +2,45 @@ import pytest from fasthtml.components import Div from fasthtml.fastapp import fast_app -from myfasthtml.core.testclient import MyTestClient, TestableElement +from myfasthtml.core.testclient import MyTestClient, TestableElement, TestableForm -def test_i_can_open_a_page(): - test_app, rt = fast_app(default_hdrs=False) - client = MyTestClient(test_app) +class TestMyTestClientOpen: - @rt('/') - def get(): return "hello world" + def test_i_can_open_a_page(self): + test_app, rt = fast_app(default_hdrs=False) + client = MyTestClient(test_app) + + @rt('/') + def get(): return "hello world" + + client.open("/") + + assert client.get_content() == "hello world" - client.open("/") + def test_i_can_open_a_page_when_html(self): + test_app, rt = fast_app(default_hdrs=False) + client = MyTestClient(test_app) + + @rt('/') + def get(): return Div("hello world") + + client.open("/") + + assert client.get_content() == ' \n \n \n FastHTML page\n \n \n \n
hello world
\n \n \n' - assert client.get_content() == "hello world" + def test_i_cannot_open_a_page_not_defined(self): + test_app, rt = fast_app(default_hdrs=False) + client = MyTestClient(test_app) + + with pytest.raises(AssertionError) as exc_info: + client.open("/not_found") + + assert str(exc_info.value) == "Failed to open '/not_found'. status code=404 : reason='404 Not Found'" -def test_i_can_open_a_page_when_html(): - test_app, rt = fast_app(default_hdrs=False) - client = MyTestClient(test_app) - - @rt('/') - def get(): return Div("hello world") - - client.open("/") - - assert client.get_content() == ' \n \n \n FastHTML page\n \n \n \n
hello world
\n \n \n' - - -def test_i_cannot_open_a_page_not_defined(): - test_app, rt = fast_app(default_hdrs=False) - client = MyTestClient(test_app) - - with pytest.raises(AssertionError) as exc_info: - client.open("/not_found") - - assert str(exc_info.value) == "Failed to open '/not_found'. status code=404 : reason='404 Not Found'" - -def test_i_can_see_text_in_plain_response(): +class TestMyTestClientShouldSee: + def test_i_can_see_text_in_plain_response(self): """Test that should_see() works with plain text responses.""" test_app, rt = fast_app(default_hdrs=False) client = MyTestClient(test_app) @@ -48,279 +50,435 @@ def test_i_can_see_text_in_plain_response(): return "hello world" client.open("/").should_see("hello world") - - -def test_i_can_see_text_in_html_response(): - """Test that should_see() extracts visible text from HTML responses.""" - test_app, rt = fast_app(default_hdrs=False) - client = MyTestClient(test_app) - @rt('/') - def get(): - return "

Welcome

This is a test

" + def test_i_can_see_text_in_html_response(self): + """Test that should_see() extracts visible text from HTML responses.""" + test_app, rt = fast_app(default_hdrs=False) + client = MyTestClient(test_app) + + @rt('/') + def get(): + return "

Welcome

This is a test

" + + client.open("/").should_see("Welcome").should_see("This is a test") - client.open("/").should_see("Welcome").should_see("This is a test") - - -def test_i_can_see_text_ignoring_html_tags(): - """Test that should_see() searches in visible text only, not in HTML tags.""" - test_app, rt = fast_app(default_hdrs=False) - client = MyTestClient(test_app) + def test_i_can_see_text_ignoring_html_tags(self): + """Test that should_see() searches in visible text only, not in HTML tags.""" + test_app, rt = fast_app(default_hdrs=False) + client = MyTestClient(test_app) + + @rt('/') + def get(): + return '
Content
' + + # Should find the visible text + client.open("/").should_see("Content") + + # Should NOT find text that's only in attributes/tags + with pytest.raises(AssertionError) as exc_info: + client.should_see("container") + + assert "Expected to see 'container' in page content but it was not found" in str(exc_info.value) - @rt('/') - def get(): - return '
Content
' + def test_i_cannot_see_text_that_is_not_present(self): + """Test that should_see() raises AssertionError when text is not found.""" + test_app, rt = fast_app(default_hdrs=False) + client = MyTestClient(test_app) + + @rt('/') + def get(): + return "hello world" + + with pytest.raises(AssertionError) as exc_info: + client.open("/").should_see("goodbye") + + assert "Expected to see 'goodbye' in page content but it was not found" in str(exc_info.value) + assert "hello world" in str(exc_info.value) - # Should find the visible text - client.open("/").should_see("Content") + def test_i_cannot_call_should_see_without_opening_page(self): + """Test that should_see() raises ValueError if no page has been opened.""" + test_app, rt = fast_app(default_hdrs=False) + client = MyTestClient(test_app) + + with pytest.raises(ValueError) as exc_info: + client.should_see("anything") + + assert str(exc_info.value) == "No page content available. Call open() before should_see()." - # Should NOT find text that's only in attributes/tags - with pytest.raises(AssertionError) as exc_info: - client.should_see("container") + def test_i_can_verify_text_is_not_present(self): + """Test that should_not_see() works when text is absent.""" + test_app, rt = fast_app(default_hdrs=False) + client = MyTestClient(test_app) + + @rt('/') + def get(): + return "hello world" + + client.open("/").should_not_see("goodbye") - assert "Expected to see 'container' in page content but it was not found" in str(exc_info.value) - - -def test_i_cannot_see_text_that_is_not_present(): - """Test that should_see() raises AssertionError when text is not found.""" - test_app, rt = fast_app(default_hdrs=False) - client = MyTestClient(test_app) + def test_i_cannot_use_should_not_see_when_text_is_present(self): + """Test that should_not_see() raises AssertionError when text is found.""" + test_app, rt = fast_app(default_hdrs=False) + client = MyTestClient(test_app) + + @rt('/') + def get(): + return "hello world" + + with pytest.raises(AssertionError) as exc_info: + client.open("/").should_not_see("hello") + + error_message = str(exc_info.value) + assert "Expected NOT to see 'hello' in page content but it was found" in error_message - @rt('/') - def get(): - return "hello world" + def test_i_cannot_call_should_not_see_without_opening_page(self): + """Test that should_not_see() raises ValueError if no page has been opened.""" + test_app, rt = fast_app(default_hdrs=False) + client = MyTestClient(test_app) + + with pytest.raises(ValueError) as exc_info: + client.should_not_see("anything") + + assert str(exc_info.value) == "No page content available. Call open() before should_not_see()." - with pytest.raises(AssertionError) as exc_info: - client.open("/").should_see("goodbye") + def test_i_can_chain_multiple_assertions(self): + """Test that assertions can be chained together.""" + test_app, rt = fast_app(default_hdrs=False) + client = MyTestClient(test_app) + + @rt('/') + def get(): + return "

Welcome

Content here

" + + # Chain multiple assertions + client.open("/").should_see("Welcome").should_see("Content").should_not_see("Error") - assert "Expected to see 'goodbye' in page content but it was not found" in str(exc_info.value) - assert "hello world" in str(exc_info.value) - - -def test_i_cannot_call_should_see_without_opening_page(): - """Test that should_see() raises ValueError if no page has been opened.""" - test_app, rt = fast_app(default_hdrs=False) - client = MyTestClient(test_app) + def test_i_can_see_element_context_when_text_should_not_be_seen(self): + """Test that the HTML element containing the text is displayed with parent context.""" + test_app, rt = fast_app(default_hdrs=False) + client = MyTestClient(test_app) + + @rt('/') + def get(): + return '

forbidden text

' + + with pytest.raises(AssertionError) as exc_info: + client.open("/").should_not_see("forbidden text") + + error_message = str(exc_info.value) + assert "Found in:" in error_message + assert '

forbidden text

' in error_message + assert '
' in error_message - with pytest.raises(ValueError) as exc_info: - client.should_see("anything") + def test_i_can_configure_parent_levels_in_constructor(self): + """Test that parent_levels parameter controls the number of parent levels shown.""" + test_app, rt = fast_app(default_hdrs=False) + client = MyTestClient(test_app, parent_levels=2) + + @rt('/') + def get(): + return '

error

' + + with pytest.raises(AssertionError) as exc_info: + client.open("/").should_not_see("error") + + error_message = str(exc_info.value) + assert '

error

' in error_message + assert '
' in error_message + assert '
' in error_message - assert str(exc_info.value) == "No page content available. Call open() before should_see()." - - -def test_i_can_verify_text_is_not_present(): - """Test that should_not_see() works when text is absent.""" - test_app, rt = fast_app(default_hdrs=False) - client = MyTestClient(test_app) + def test_i_can_find_text_in_nested_elements(self): + """Test that the smallest element containing the text is found in nested structures.""" + test_app, rt = fast_app(default_hdrs=False) + client = MyTestClient(test_app) + + @rt('/') + def get(): + return '

nested text

' + + with pytest.raises(AssertionError) as exc_info: + client.open("/").should_not_see("nested text") + + error_message = str(exc_info.value) + # Should find the

element, not the outer

+ assert '

nested text

' in error_message - @rt('/') - def get(): - return "hello world" + def test_i_can_find_fragmented_text_across_tags(self): + """Test that text fragmented across multiple tags is correctly found.""" + test_app, rt = fast_app(default_hdrs=False) + client = MyTestClient(test_app) + + @rt('/') + def get(): + return '

hello world

' + + with pytest.raises(AssertionError) as exc_info: + client.open("/").should_not_see("hello world") + + error_message = str(exc_info.value) + # Should find the parent

element that contains the full text + assert '

' in error_message - client.open("/").should_not_see("goodbye") - - -def test_i_cannot_use_should_not_see_when_text_is_present(): - """Test that should_not_see() raises AssertionError when text is found.""" - test_app, rt = fast_app(default_hdrs=False) - client = MyTestClient(test_app) - - @rt('/') - def get(): - return "hello world" - - with pytest.raises(AssertionError) as exc_info: - client.open("/").should_not_see("hello") - - error_message = str(exc_info.value) - assert "Expected NOT to see 'hello' in page content but it was found" in error_message - - -def test_i_cannot_call_should_not_see_without_opening_page(): - """Test that should_not_see() raises ValueError if no page has been opened.""" - test_app, rt = fast_app(default_hdrs=False) - client = MyTestClient(test_app) - - with pytest.raises(ValueError) as exc_info: - client.should_not_see("anything") - - assert str(exc_info.value) == "No page content available. Call open() before should_not_see()." - - -def test_i_can_chain_multiple_assertions(): - """Test that assertions can be chained together.""" - test_app, rt = fast_app(default_hdrs=False) - client = MyTestClient(test_app) - - @rt('/') - def get(): - return "

Welcome

Content here

" - - # Chain multiple assertions - client.open("/").should_see("Welcome").should_see("Content").should_not_see("Error") - - -def test_i_can_see_element_context_when_text_should_not_be_seen(): - """Test that the HTML element containing the text is displayed with parent context.""" - test_app, rt = fast_app(default_hdrs=False) - client = MyTestClient(test_app) - - @rt('/') - def get(): - return '

forbidden text

' - - with pytest.raises(AssertionError) as exc_info: - client.open("/").should_not_see("forbidden text") - - error_message = str(exc_info.value) - assert "Found in:" in error_message - assert '

forbidden text

' in error_message - assert '
' in error_message - - -def test_i_can_configure_parent_levels_in_constructor(): - """Test that parent_levels parameter controls the number of parent levels shown.""" - test_app, rt = fast_app(default_hdrs=False) - client = MyTestClient(test_app, parent_levels=2) - - @rt('/') - def get(): - return '

error

' - - with pytest.raises(AssertionError) as exc_info: + def test_i_do_not_find_text_in_html_attributes(self): + """Test that text in HTML attributes is not considered as visible text.""" + test_app, rt = fast_app(default_hdrs=False) + client = MyTestClient(test_app) + + @rt('/') + def get(): + return '
Success
' + + # "error" is in attributes but not in visible text client.open("/").should_not_see("error") + + # "Success" is in visible text + with pytest.raises(AssertionError): + client.should_not_see("Success") + + +class TestMyTestClientFindElement: - error_message = str(exc_info.value) - assert '

error

' in error_message - assert '
' in error_message - assert '
' in error_message - - -def test_i_can_find_text_in_nested_elements(): - """Test that the smallest element containing the text is found in nested structures.""" - test_app, rt = fast_app(default_hdrs=False) - client = MyTestClient(test_app) - - @rt('/') - def get(): - return '

nested text

' - - with pytest.raises(AssertionError) as exc_info: - client.open("/").should_not_see("nested text") - - error_message = str(exc_info.value) - # Should find the

element, not the outer

- assert '

nested text

' in error_message - - -def test_i_can_find_fragmented_text_across_tags(): - """Test that text fragmented across multiple tags is correctly found.""" - test_app, rt = fast_app(default_hdrs=False) - client = MyTestClient(test_app) - - @rt('/') - def get(): - return '

hello world

' - - with pytest.raises(AssertionError) as exc_info: - client.open("/").should_not_see("hello world") - - error_message = str(exc_info.value) - # Should find the parent

element that contains the full text - assert '

' in error_message - - -def test_i_do_not_find_text_in_html_attributes(): - """Test that text in HTML attributes is not considered as visible text.""" - test_app, rt = fast_app(default_hdrs=False) - client = MyTestClient(test_app) - - @rt('/') - def get(): - return '

Success
' - - # "error" is in attributes but not in visible text - client.open("/").should_not_see("error") - - # "Success" is in visible text - with pytest.raises(AssertionError): - client.should_not_see("Success") - - -@pytest.mark.parametrize("selector,expected_tag", [ - ("#unique-id", '
div", '
'), - ('[data-author*="john"]', ' - Home - About -
- Content + @pytest.mark.parametrize("selector,expected_tag", [ + ("#unique-id", '
div", '
'), + ('[data-author*="john"]', ' + Home + About +
+ Content +
-
- ''' - - element = client.open("/").find_element(selector) - - assert element is not None - assert isinstance(element, TestableElement) - assert expected_tag in element.html_fragment + ''' + + element = client.open("/").find_element(selector) + + assert element is not None + assert isinstance(element, TestableElement) + assert expected_tag in element.html_fragment + + def test_i_cannot_find_element_when_none_exists(self): + """Test that find_element raises AssertionError when no element matches.""" + test_app, rt = fast_app(default_hdrs=False) + client = MyTestClient(test_app) + + @rt('/') + def get(): + return '

Content

' + + with pytest.raises(AssertionError) as exc_info: + client.open("/").find_element("#non-existent") + + assert "No element found matching selector '#non-existent'" in str(exc_info.value) + + def test_i_cannot_find_element_when_multiple_exist(self): + """Test that find_element raises AssertionError when multiple elements match.""" + test_app, rt = fast_app(default_hdrs=False) + client = MyTestClient(test_app) + + @rt('/') + def get(): + return '

First

Second

Third

' + + with pytest.raises(AssertionError) as exc_info: + client.open("/").find_element(".text") + + error_message = str(exc_info.value) + assert "Found 3 elements matching selector '.text'" in error_message + assert "Expected exactly 1" in error_message + + def test_i_cannot_call_find_element_without_opening_page(self): + """Test that find_element raises ValueError if no page has been opened.""" + test_app, rt = fast_app(default_hdrs=False) + client = MyTestClient(test_app) + + with pytest.raises(ValueError) as exc_info: + client.find_element("#any-selector") + + assert str(exc_info.value) == "No page content available. Call open() before find_element()." -def test_i_cannot_find_element_when_none_exists(): - """Test that find_element raises AssertionError when no element matches.""" - test_app, rt = fast_app(default_hdrs=False) - client = MyTestClient(test_app) +class TestMyTestClientFindForm: + def test_i_can_find_form(self): + """Test that find_form works fo simple form.""" + test_app, rt = fast_app(default_hdrs=False) + client = MyTestClient(test_app) + + @rt('/') + def get(): + return '''
''' + + form = client.open("/").find_form() + + assert form is not None + assert isinstance(form, TestableForm) + + def test_i_can_find_form_in_nested_elements(self): + """Test that find_form works when form is nested in other elements.""" + test_app, rt = fast_app(default_hdrs=False) + client = MyTestClient(test_app) + + @rt('/') + def get(): + return ''' +
+
+
+
+
+ ''' + + form = client.open("/").find_form() + + assert form is not None + assert isinstance(form, TestableForm) + + def test_i_can_find_form_with_all_fields(self): + """Test that find_form works when form has fields.""" + test_app, rt = fast_app(default_hdrs=False) + client = MyTestClient(test_app) + + @rt('/') + def get(): + return ''' +
+ + + +
+ ''' + + form = client.open("/").find_form(fields=["username", "password"]) + + assert form is not None + assert isinstance(form, TestableForm) + + def test_i_can_find_form_with_label(self): + """Test that find_form works when form has fields.""" + test_app, rt = fast_app(default_hdrs=False) + client = MyTestClient(test_app) + + @rt('/') + def get(): + return ''' +
+ + + + +
+ ''' + + form = client.open("/").find_form(fields=["Username", "password"]) + + assert form is not None + assert isinstance(form, TestableForm) + + def test_i_can_find_form_with_one_field(self): + """Test that find_form works when form has at least one field.""" + test_app, rt = fast_app(default_hdrs=False) + client = MyTestClient(test_app) + + @rt('/') + def get(): + return ''' +
+ + + +
+ ''' + + form = client.open("/").find_form(fields=["username"]) + + assert form is not None + assert isinstance(form, TestableForm) + + def test_i_cannot_find_element_when_none_exists(self): + """Test that find_form raises AssertionError when no form found.""" + test_app, rt = fast_app(default_hdrs=False) + client = MyTestClient(test_app) + + @rt('/') + def get(): + return '

Content

' + + with pytest.raises(AssertionError) as exc_info: + client.open("/").find_form() + + assert "No form found" in str(exc_info.value) + + def test_i_cannot_call_find_form_without_opening_page(self): + """Test that find_form raises ValueError if no page has been opened.""" + test_app, rt = fast_app(default_hdrs=False) + client = MyTestClient(test_app) + + with pytest.raises(ValueError) as exc_info: + client.find_form() + + assert str(exc_info.value) == "No page content available. Call open() before find_form()." + + def test_cannot_find_form_when_multiple_exist(self): + """Test that find_form raises AssertionError when multiple forms match.""" + test_app, rt = fast_app(default_hdrs=False) + client = MyTestClient(test_app) + + @rt('/') + def get(): + return ''' +
+
+
+
+ ''' + + with pytest.raises(AssertionError) as exc_info: + client.open("/").find_form() + + error_message = str(exc_info.value) + assert "Found 2 forms (with the specified fields). Expected exactly 1." in error_message + + def test_cannot_find_form_with_fields_when_multiple_exist(self): + """Test that find_form raises AssertionError when multiple forms match.""" + test_app, rt = fast_app(default_hdrs=False) + client = MyTestClient(test_app) + + @rt('/') + def get(): + return ''' +
+
+ + + +
+
+ + + +
+
+ ''' + + with pytest.raises(AssertionError) as exc_info: + client.open("/").find_form(fields=["username", "password"]) + + error_message = str(exc_info.value) + assert "Found 2 forms (with the specified fields). Expected exactly 1." in error_message - @rt('/') - def get(): - return '

Content

' - - with pytest.raises(AssertionError) as exc_info: - client.open("/").find_element("#non-existent") - - assert "No element found matching selector '#non-existent'" in str(exc_info.value) - - -def test_i_cannot_find_element_when_multiple_exist(): - """Test that find_element raises AssertionError when multiple elements match.""" - test_app, rt = fast_app(default_hdrs=False) - client = MyTestClient(test_app) - - @rt('/') - def get(): - return '

First

Second

Third

' - - with pytest.raises(AssertionError) as exc_info: - client.open("/").find_element(".text") - - error_message = str(exc_info.value) - assert "Found 3 elements matching selector '.text'" in error_message - assert "Expected exactly 1" in error_message - - -def test_i_cannot_call_find_element_without_opening_page(): - """Test that find_element raises ValueError if no page has been opened.""" - test_app, rt = fast_app(default_hdrs=False) - client = MyTestClient(test_app) - - with pytest.raises(ValueError) as exc_info: - client.find_element("#any-selector") - - assert str(exc_info.value) == "No page content available. Call open() before find_element()." diff --git a/tests/testclient/test_testable_form.py b/tests/testclient/test_testable_form.py index 33914e2..ff690aa 100644 --- a/tests/testclient/test_testable_form.py +++ b/tests/testclient/test_testable_form.py @@ -1,6 +1,7 @@ import pytest +from fasthtml.fastapp import fast_app -from myfasthtml.core.testclient import TestableForm +from myfasthtml.core.testclient import TestableForm, MyTestClient @pytest.fixture @@ -583,3 +584,339 @@ class TestableFormUpdateFieldValues: f"Expected {{'flag': True}}, got {form.fields}" assert isinstance(form.fields["flag"], bool), \ f"Expected bool type, got {type(form.fields['flag'])}" + + +class TestMyTestClientFill: + def test_i_can_fill_form_using_input_name(self, mock_client): + """ + I can fill using the input name + """ + html = '
' + form = TestableForm(mock_client, html) + + form.fill(username="john_doe") + + assert form.fields == {"username": "john_doe"} + + def test_i_can_fill_form_using_label(self, mock_client): + """ + I can fill using the label associated with the input + """ + html = '
' + form = TestableForm(mock_client, html) + + form.fill(Username="john_doe") + + assert form.fields == {"username": "john_doe"} + + def test_i_cannot_fill_form_with_invalid_field_name(self, mock_client): + """ + I cannot fill form with invalid field name + """ + html = '
' + form = TestableForm(mock_client, html) + + with pytest.raises(ValueError) as excinfo: + form.fill(invalid_field="john_doe") + + assert str(excinfo.value) == "Invalid field name 'invalid_field'." + + +class TestableFormSubmit: + """ + Test suite for the submit() method of TestableForm class. + This module tests form submission for both HTMX and classic forms. + """ + + def test_i_can_submit_classic_form_with_post_method(self): + """ + Test that a classic form with POST method is submitted correctly. + + This ensures the form uses the action and method attributes properly. + """ + # HTML form with classic submission + html = ''' +
+ + +
+ ''' + + # Create the form + test_app, rt = fast_app(default_hdrs=False) + client = MyTestClient(test_app) + form = TestableForm(client, html) + + @rt('/submit') + def post(username: str, password: str): + return f"Form received {username=}, {password=}" + + form.submit() + assert client.get_content() == "Form received username='john_doe', password='secret123'" + + def test_i_can_submit_classic_form_with_get_method(self): + """ + Test that a classic form with GET method is submitted correctly. + + This ensures GET requests are properly handled. + """ + html = ''' +
+ + +
+ ''' + + test_app, rt = fast_app(default_hdrs=False) + client = MyTestClient(test_app) + form = TestableForm(client, html) + + @rt('/search') + def get(q: str, page: int): + return f"Search results for {q=}, {page=}" + + form.submit() + assert client.get_content() == "Search results for q='python', page=1" + + def test_i_can_submit_classic_form_without_method_defaults_to_post(self): + """ + Test that POST is used by default when method attribute is absent. + + This ensures proper default behavior matching HTML standards. + """ + html = ''' +
+ +
+ ''' + + test_app, rt = fast_app(default_hdrs=False) + client = MyTestClient(test_app) + form = TestableForm(client, html) + + @rt('/submit') + def post(data: str): + return f"Received {data=}" + + form.submit() + assert client.get_content() == "Received data='test'" + + def test_i_can_submit_form_with_htmx_post(self): + """ + Test that a form with hx_post uses HTMX submission. + + This ensures HTMX-enabled forms are handled correctly. + """ + html = ''' +
+ +
+ ''' + + test_app, rt = fast_app(default_hdrs=False) + client = MyTestClient(test_app) + form = TestableForm(client, html) + + @rt('/htmx-submit') + def post(username: str): + return f"HTMX received {username=}" + + form.submit() + assert client.get_content() == "HTMX received username='alice'" + + def test_i_can_submit_form_with_htmx_get(self): + """ + Test that a form with hx_get uses HTMX submission. + + This ensures HTMX GET requests work properly. + """ + html = ''' +
+ +
+ ''' + + test_app, rt = fast_app(default_hdrs=False) + client = MyTestClient(test_app) + form = TestableForm(client, html) + + @rt('/htmx-search') + def get(query: str): + return f"HTMX search for {query=}" + + form.submit() + assert client.get_content() == "HTMX search for query='fasthtml'" + + def test_i_can_submit_form_with_filled_fields(self): + """ + Test that fields filled via fill_form() are submitted correctly. + + This ensures dynamic form filling works as expected. + """ + html = ''' +
+ + +
+ ''' + + test_app, rt = fast_app(default_hdrs=False) + client = MyTestClient(test_app) + form = TestableForm(client, html) + + # Fill the form dynamically + form.fill(username="bob", password="secure456") + + @rt('/login') + def post(username: str, password: str): + return f"Login {username=}, {password=}" + + form.submit() + assert client.get_content() == "Login username='bob', password='secure456'" + + def test_i_can_submit_form_with_mixed_field_types(self): + """ + Test that all field types are submitted with correct values. + + This ensures type conversion and submission work together. + """ + html = ''' +
+ + + + +
+ ''' + + test_app, rt = fast_app(default_hdrs=False) + client = MyTestClient(test_app) + form = TestableForm(client, html) + + @rt('/register') + def post(username: str, age: int, newsletter: bool, country: str): + return f"Registration {username=}, {age=}, {newsletter=}, {country=}" + + result = form.submit() + + # Note: the types are converted in self.fields + expected = "Registration username='charlie', age=30, newsletter=True, country='US'" + assert client.get_content() == expected + + def test_i_cannot_submit_classic_form_without_action(self): + """ + Test that an exception is raised when action attribute is missing. + + This ensures proper error handling for malformed forms. + """ + html = ''' +
+ +
+ ''' + + test_app, rt = fast_app(default_hdrs=False) + client = MyTestClient(test_app) + form = TestableForm(client, html) + + # Should raise ValueError + try: + form.submit() + assert False, "Expected ValueError to be raised" + except ValueError as e: + assert "no 'action' attribute" in str(e).lower() + + def test_i_cannot_submit_classic_form_with_empty_action(self): + """ + Test that an exception is raised when action attribute is empty. + + This ensures validation of the action attribute. + """ + html = ''' +
+ +
+ ''' + + test_app, rt = fast_app(default_hdrs=False) + client = MyTestClient(test_app) + form = TestableForm(client, html) + + # Should raise ValueError + try: + form.submit() + assert False, "Expected ValueError to be raised" + except ValueError as e: + assert "no 'action' attribute" in str(e).lower() + + def test_i_can_submit_form_with_case_insensitive_method(self): + """ + Test that HTTP method is properly normalized to uppercase. + + This ensures method attribute is case-insensitive. + """ + html = ''' +
+ +
+ ''' + + test_app, rt = fast_app(default_hdrs=False) + client = MyTestClient(test_app) + form = TestableForm(client, html) + + @rt('/submit') + def post(data: str): + return f"Received {data=}" + + form.submit() + assert client.get_content() == "Received data='test'" + + def test_i_can_prioritize_htmx_over_classic_submission(self): + """ + Test that HTMX is prioritized even when action/method are present. + + This ensures correct priority between HTMX and classic submission. + """ + html = ''' +
+ +
+ ''' + + test_app, rt = fast_app(default_hdrs=False) + client = MyTestClient(test_app) + form = TestableForm(client, html) + + @rt('/classic') + def post_classic(data: str): + return "Classic submission" + + @rt('/htmx') + def post_htmx(data: str): + return "HTMX submission" + + form.submit() + assert client.get_content() == "HTMX submission" + + def test_i_can_submit_empty_form(self): + """ + Test that a form without fields can be submitted. + + This ensures robustness for minimal forms. + """ + html = '
' + + test_app, rt = fast_app(default_hdrs=False) + client = MyTestClient(test_app) + form = TestableForm(client, html) + + @rt('/empty') + def post(): + return "Empty form received" + + form.submit() + assert client.get_content() == "Empty form received"