feat: add web UI, query engine, session management, and 20 E2E tests

- 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)
This commit is contained in:
DavidSaylor
2026-05-22 07:46:53 -05:00
parent 77b076c7d7
commit fe4cbc3537
29 changed files with 1410 additions and 78 deletions

View File

@@ -0,0 +1,350 @@
"""
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()