""" LinkSyncServer - End-to-End Playwright Tests """ import os import subprocess import time import pytest pytestmark = pytest.mark.e2e import requests as _requests def login(page, base_url, username="admin", password="admin123"): try: _requests.get(f"{base_url}/health", timeout=5) except Exception: pytest.skip("Server not healthy") page.goto(f"{base_url}/login") page.fill("#username", username) page.fill("#password", password) page.click("#login-btn") page.wait_for_url("**/dashboard") @pytest.fixture(scope="session") def e2e_server(): """Start the dev server for E2E tests.""" import requests env = os.environ.copy() env["DATABASE_URL"] = "sqlite:///test_e2e.db" env["SECRET_KEY"] = "e2e-test-secret-key" env["ADMIN_USERNAME"] = "admin" env["ADMIN_PASSWORD"] = "admin123" env["DEBUG"] = "False" env["PORT"] = "5001" proc = subprocess.Popen( ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "5001"], env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) base_url = "http://localhost:5001" for _ in range(30): try: resp = requests.get(f"{base_url}/health", timeout=2) if resp.ok: break except requests.RequestException: pass time.sleep(1) else: proc.terminate() proc.wait(timeout=5) raise RuntimeError("Server failed to start") yield base_url proc.terminate() proc.wait(timeout=5) try: if os.path.exists("test_e2e.db"): os.remove("test_e2e.db") except PermissionError: pass @pytest.fixture(autouse=True) def setup_page(page, e2e_server): """Set base URL for all E2E tests.""" page.set_default_timeout(30000) return page class TestAuth: def test_login_and_dashboard(self, page, e2e_server): login(page, e2e_server) assert page.locator("#current-user").text_content() == "admin" assert page.locator("#link-count").is_visible() assert page.locator("#collection-count").is_visible() assert page.locator("#api-key-count").is_visible() def test_dashboard_stats_show_numbers(self, page, e2e_server): login(page, e2e_server) page.locator("#link-count:not(:text('-'))").wait_for(timeout=5000) link_count = page.locator("#link-count").text_content() assert link_count != "-" collection_count = page.locator("#collection-count").text_content() assert collection_count != "-" api_key_count = page.locator("#api-key-count").text_content() assert api_key_count != "-" def test_logout_redirects_to_login(self, page, e2e_server): login(page, e2e_server) page.click("#logout-btn") page.wait_for_url("**/login") assert page.url.endswith("/login") def test_session_expired_redirect(self, page, e2e_server): page.goto(f"{e2e_server}/login?expired=1") assert page.locator("#session-expired").is_visible() class TestLinks: def test_create_link(self, page, e2e_server): login(page, e2e_server) page.goto(f"{e2e_server}/links") page.wait_for_timeout(500) page.click("#new-link-btn") page.wait_for_selector("#link-modal") page.fill("#link-url", "https://example.com/test") page.fill("#link-title", "Test Link") page.fill("#link-description", "A test link") page.fill("#link-tags", "test, example") page.click("#save-btn") page.wait_for_timeout(500) assert page.locator("#links-list").locator("text=Test Link").first.is_visible() def test_search_links_simple(self, page, e2e_server): login(page, e2e_server) page.goto(f"{e2e_server}/links") page.wait_for_timeout(500) page.click("#new-link-btn") page.wait_for_selector("#link-modal") page.fill("#link-url", "https://example.com/searchable") page.fill("#link-title", "Searchable Link") page.fill("#link-tags", "search, demo") page.click("#save-btn") page.wait_for_timeout(500) page.fill("#search-input", "Searchable") page.click("#search-btn") page.wait_for_timeout(500) assert page.locator("#links-list").locator("text=Searchable Link").first.is_visible() def test_search_links_by_tag(self, page, e2e_server): login(page, e2e_server) page.goto(f"{e2e_server}/links") page.wait_for_timeout(500) page.click("#new-link-btn") page.wait_for_selector("#link-modal") page.fill("#link-url", "https://example.com/tagged") page.fill("#link-title", "Tagged Link") page.fill("#link-tags", "mytag, unique") page.click("#save-btn") page.wait_for_timeout(500) page.fill("#search-input", "mytag") page.click("#search-btn") page.wait_for_timeout(500) assert page.locator("#links-list").locator("text=Tagged Link").first.is_visible() def test_multi_word_search(self, page, e2e_server): login(page, e2e_server) page.goto(f"{e2e_server}/links") page.wait_for_timeout(500) page.click("#new-link-btn") page.wait_for_selector("#link-modal") page.fill("#link-url", "https://example.com/multiword") page.fill("#link-title", "MultiWord Link") page.fill("#link-tags", "alpha, beta") page.click("#save-btn") page.wait_for_timeout(500) page.fill("#search-input", "MultiWord alpha") page.click("#search-btn") page.wait_for_timeout(500) assert page.locator("#links-list").locator("text=MultiWord Link").first.is_visible() def _do_query_search(self, page, query): page.click("#query-search-btn") page.wait_for_timeout(300) page.fill("#query-input", query) page.click("#search-btn") page.wait_for_timeout(1000) page.wait_for_selector("#links-list .data-table, #links-list .empty-state", timeout=10000) def test_query_mode_search(self, page, e2e_server): login(page, e2e_server) page.goto(f"{e2e_server}/links") page.wait_for_selector("#links-list .data-table", timeout=10000) self._do_query_search(page, "alpha") assert page.locator("#links-list").locator("text=MultiWord Link").first.is_visible() def test_query_mode_with_and(self, page, e2e_server): login(page, e2e_server) page.goto(f"{e2e_server}/links") page.wait_for_selector("#links-list .data-table", timeout=10000) self._do_query_search(page, "MultiWord AND alpha") assert page.locator("#links-list").locator("text=MultiWord Link").first.is_visible() def test_query_mode_with_or(self, page, e2e_server): login(page, e2e_server) page.goto(f"{e2e_server}/links") page.wait_for_selector("#links-list .data-table", timeout=10000) self._do_query_search(page, "MultiWord OR nonexistent") assert page.locator("#links-list").locator("text=MultiWord Link").first.is_visible() def test_delete_link(self, page, e2e_server): login(page, e2e_server) page.goto(f"{e2e_server}/links") page.wait_for_timeout(500) page.click("#new-link-btn") page.wait_for_selector("#link-modal") page.fill("#link-url", "https://example.com/deletable") page.fill("#link-title", "Deletable Link") page.click("#save-btn") page.wait_for_timeout(500) page.locator('[data-action="delete"]').first.click() page.wait_for_selector("#delete-modal") page.click("#confirm-delete-btn") page.wait_for_timeout(500) assert not page.locator("#links-list").locator("text=Deletable Link").first.is_visible() def test_save_search_as_static_collection(self, page, e2e_server): login(page, e2e_server) page.goto(f"{e2e_server}/links") page.wait_for_timeout(500) page.fill("#search-input", "MultiWord") page.click("#search-btn") page.wait_for_timeout(500) page.click("#save-collection-btn") page.wait_for_selector("#save-collection-modal") page.fill("#save-collection-name", "Saved Search Collection") page.click("#save-collection-save-btn") page.wait_for_timeout(500) page.goto(f"{e2e_server}/collections") page.wait_for_timeout(500) assert page.locator(".collection-card").locator("text=Saved Search Collection").first.is_visible() def test_save_query_as_dynamic_collection(self, page, e2e_server): login(page, e2e_server) page.goto(f"{e2e_server}/links") page.wait_for_timeout(500) page.click("#query-search-btn") page.wait_for_selector("#query-input") page.fill("#query-input", "alpha") page.click("#search-btn") page.wait_for_timeout(500) page.click("#save-collection-btn") page.wait_for_selector("#save-collection-modal") page.fill("#save-collection-name", "Dynamic Query Collection") page.click("#save-collection-save-btn") page.wait_for_timeout(500) page.goto(f"{e2e_server}/collections") page.wait_for_timeout(500) assert page.locator(".collection-card").locator("text=Dynamic Query Collection").first.is_visible() class TestCollections: def test_create_static_collection(self, page, e2e_server): login(page, e2e_server) page.goto(f"{e2e_server}/collections") page.click("#new-collection-btn") page.wait_for_selector("#collection-modal") page.fill("#collection-name", "Static Test Collection") page.fill("#collection-description", "A static collection") page.select_option("#collection-type", "static") page.click("#collection-save-btn") page.wait_for_timeout(500) assert page.locator(".collection-card").locator("text=Static Test Collection").first.is_visible() def test_create_dynamic_collection_with_query(self, page, e2e_server): login(page, e2e_server) page.goto(f"{e2e_server}/collections") page.click("#new-collection-btn") page.wait_for_selector("#collection-modal") page.fill("#collection-name", "Dynamic Test Collection") page.select_option("#collection-type", "dynamic") page.wait_for_selector("#query-builder-section") page.fill("#collection-query", "alpha OR beta") page.click("#preview-query-btn") page.wait_for_timeout(500) assert page.locator("#query-preview-results").is_visible() page.click("#collection-save-btn") page.wait_for_timeout(500) assert page.locator(".collection-card").locator("text=Dynamic Test Collection").first.is_visible() def test_delete_collection(self, page, e2e_server): login(page, e2e_server) page.goto(f"{e2e_server}/collections") page.click("#new-collection-btn") page.wait_for_selector("#collection-modal") page.fill("#collection-name", "Delete Me Collection") page.click("#collection-save-btn") page.wait_for_timeout(500) page.locator('[data-action="delete"]').first.click() page.wait_for_selector("#delete-collection-modal") page.click("#confirm-delete-collection-btn") page.wait_for_timeout(500) assert not page.locator(".collection-card").locator("text=Delete Me Collection").first.is_visible() class TestApiKeys: def test_create_api_key(self, page, e2e_server): login(page, e2e_server) page.goto(f"{e2e_server}/api-keys") page.click("#new-key-btn") page.wait_for_selector("#key-modal") page.fill("#key-name", "Test Key") page.click("#key-save-btn") page.wait_for_selector("#key-result-modal", timeout=5000) page.click("#key-done-btn") page.wait_for_timeout(500) assert page.locator("#api-keys-list").locator("text=Test Key").first.is_visible() def test_delete_api_key(self, page, e2e_server): login(page, e2e_server) page.goto(f"{e2e_server}/api-keys") page.click("#new-key-btn") page.wait_for_selector("#key-modal") page.fill("#key-name", "Delete Me Key") page.click("#key-save-btn") page.wait_for_selector("#key-result-modal", timeout=5000) page.click("#key-done-btn") page.wait_for_timeout(500) page.locator('[data-action="delete"]').first.click() page.wait_for_selector("#delete-key-modal") page.click("#confirm-delete-key-btn") page.wait_for_timeout(500) assert not page.locator(".data-table").locator("text=Delete Me Key").first.is_visible() class TestAdmin: def test_admin_user_management(self, page, e2e_server): login(page, e2e_server) page.goto(f"{e2e_server}/admin") page.wait_for_selector("#users-list .data-table", timeout=10000) page.click("#new-user-btn") page.wait_for_selector("#user-modal") page.fill("#user-username", "testuser") page.fill("#user-email", "test@example.com") page.fill("#user-password", "testpass123") page.click("#user-save-btn") page.wait_for_selector("#users-list .data-table td", timeout=5000) assert page.locator("#users-list .data-table").locator("text=testuser").first.is_visible()