- Web UI: login, dashboard, links CRUD, collections, API keys, admin pages - Query engine: AND/OR/XOR with field filters, tag search, preview endpoint - Session management: token expiry detection, 401 interceptor, expiry banner - Links search: tags included, multi-word AND, query mode with set operations - Collections: static/dynamic, query builder with preview, public tree view - Save as Collection: convert search results (static) or query (dynamic) - Dashboard stats: resilient loading with allSettled pattern - Login page: redesigned with public collections tree view - Bug fix: query executor None fields crash (notes/description/url/title) - E2E tests: 20 Playwright tests covering all critical user flows - All 104 tests passing (84 unit/integration + 20 E2E)
351 lines
13 KiB
Python
351 lines
13 KiB
Python
"""
|
|
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()
|