refactor: move import to module level and extract event handler
- Move ProgressType import to top-level in auth.py - Extract suggestion link click handler into attachSuggestionLinkEvents() function - Reuse handler after search results load
This commit is contained in:
21
Makefile
Normal file
21
Makefile
Normal file
@@ -0,0 +1,21 @@
|
||||
.PHONY: up down clean browser-clean setup
|
||||
|
||||
up:
|
||||
python run_server.py
|
||||
|
||||
down:
|
||||
pkill -f "uvicorn src.server.fastapi_app:app" || pkill -f "python.*run_server.py" || true
|
||||
|
||||
clean:
|
||||
rm -rf data/*.db data/*.db-shm data/*.db-wal data/config.json
|
||||
|
||||
browser-clean:
|
||||
rm -rf "$$HOME/.cache/microsoft-edge"/* || true
|
||||
rm -rf "$$HOME/.cache/mozilla/firefox"/* || true
|
||||
find "$$HOME/.mozilla/firefox" -name "cache2" -type d -exec rm -rf {} \; 2>/dev/null || true
|
||||
|
||||
setup:
|
||||
curl -X POST http://127.0.0.1:8000/setup \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-Key: 299ae8f630a31bda814263c551361448" \
|
||||
-d '{"path": "/home/lukas/Volume/serien/", "password": "Hallo123!"}'
|
||||
@@ -16,6 +16,7 @@ from src.server.models.auth import (
|
||||
from src.server.models.config import AppConfig
|
||||
from src.server.services.auth_service import AuthError, LockedOutError, auth_service
|
||||
from src.server.services.config_service import get_config_service
|
||||
from src.server.services.progress_service import ProgressType
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
@@ -194,7 +195,6 @@ async def setup_auth(req: SetupRequest):
|
||||
)
|
||||
except Exception as e:
|
||||
# Send error event
|
||||
from src.server.services.progress_service import ProgressType
|
||||
await progress_service.start_progress(
|
||||
progress_id="initialization_error",
|
||||
progress_type=ProgressType.ERROR,
|
||||
|
||||
@@ -665,6 +665,60 @@
|
||||
}
|
||||
}
|
||||
|
||||
function attachSuggestionLinkEvents() {
|
||||
document.querySelectorAll('.suggestion-link').forEach(link => {
|
||||
link.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
const providerKey = e.target.dataset.providerKey;
|
||||
const folder = e.target.dataset.folder;
|
||||
|
||||
if (!providerKey) {
|
||||
showToast('No provider key available for this suggestion', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const input = document.querySelector(`.folder-input[data-folder="${folder}"]`);
|
||||
const resolveBtn = document.querySelector(`.resolve-btn[data-folder="${folder}"]`);
|
||||
const item = document.querySelector(`.folder-item[data-folder="${folder}"]`);
|
||||
const errEl = document.querySelector(`.folder-error[data-folder="${folder}"]`);
|
||||
|
||||
if (!input || !resolveBtn || !item) return;
|
||||
|
||||
input.value = providerKey;
|
||||
resolveBtn.disabled = false;
|
||||
|
||||
item.classList.add('resolving');
|
||||
resolveBtn.disabled = true;
|
||||
resolveBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
|
||||
|
||||
try {
|
||||
const result = await resolveFolder(folder, providerKey);
|
||||
|
||||
if (result.status === 'success') {
|
||||
showToast(`Added: ${result.message.replace('Successfully resolved and added series: ', '')}`, 'success');
|
||||
item.classList.add('resolved');
|
||||
setTimeout(() => {
|
||||
item.remove();
|
||||
checkEmptyList();
|
||||
}, 400);
|
||||
} else {
|
||||
errEl.textContent = result.detail || result.message || 'Failed to resolve';
|
||||
errEl.classList.add('visible');
|
||||
resolveBtn.disabled = false;
|
||||
resolveBtn.innerHTML = 'Resolve';
|
||||
}
|
||||
} catch (err) {
|
||||
errEl.textContent = 'Server error. Please try again.';
|
||||
errEl.classList.add('visible');
|
||||
resolveBtn.disabled = false;
|
||||
resolveBtn.innerHTML = 'Resolve';
|
||||
} finally {
|
||||
item.classList.remove('resolving');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function attachFolderEvents() {
|
||||
// Input enable/disable resolve button
|
||||
document.querySelectorAll('.folder-input').forEach(input => {
|
||||
@@ -779,6 +833,7 @@
|
||||
// Keep search row visible for additional searches
|
||||
btn.classList.remove('searching');
|
||||
btn.innerHTML = '<i class="fas fa-search"></i> Search Again';
|
||||
attachSuggestionLinkEvents();
|
||||
} catch (err) {
|
||||
showToast('Search failed', 'error');
|
||||
btn.classList.remove('searching');
|
||||
@@ -790,59 +845,7 @@
|
||||
});
|
||||
|
||||
// Suggestion link click - populate input and resolve
|
||||
document.querySelectorAll('.suggestion-link').forEach(link => {
|
||||
link.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
const providerKey = e.target.dataset.providerKey;
|
||||
const folder = e.target.dataset.folder;
|
||||
|
||||
if (!providerKey) {
|
||||
showToast('No provider key available for this suggestion', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const input = document.querySelector(`.folder-input[data-folder="${folder}"]`);
|
||||
const resolveBtn = document.querySelector(`.resolve-btn[data-folder="${folder}"]`);
|
||||
const item = document.querySelector(`.folder-item[data-folder="${folder}"]`);
|
||||
const errEl = document.querySelector(`.folder-error[data-folder="${folder}"]`);
|
||||
|
||||
if (!input || !resolveBtn || !item) return;
|
||||
|
||||
// Populate input and enable button
|
||||
input.value = providerKey;
|
||||
resolveBtn.disabled = false;
|
||||
|
||||
// Trigger resolve
|
||||
item.classList.add('resolving');
|
||||
resolveBtn.disabled = true;
|
||||
resolveBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
|
||||
|
||||
try {
|
||||
const result = await resolveFolder(folder, providerKey);
|
||||
|
||||
if (result.status === 'success') {
|
||||
showToast(`Added: ${result.message.replace('Successfully resolved and added series: ', '')}`, 'success');
|
||||
item.classList.add('resolved');
|
||||
setTimeout(() => {
|
||||
item.remove();
|
||||
checkEmptyList();
|
||||
}, 400);
|
||||
} else {
|
||||
errEl.textContent = result.detail || result.message || 'Failed to resolve';
|
||||
errEl.classList.add('visible');
|
||||
resolveBtn.disabled = false;
|
||||
resolveBtn.innerHTML = 'Resolve';
|
||||
}
|
||||
} catch (err) {
|
||||
errEl.textContent = 'Server error. Please try again.';
|
||||
errEl.classList.add('visible');
|
||||
resolveBtn.disabled = false;
|
||||
resolveBtn.innerHTML = 'Resolve';
|
||||
} finally {
|
||||
item.classList.remove('resolving');
|
||||
}
|
||||
});
|
||||
});
|
||||
attachSuggestionLinkEvents();
|
||||
}
|
||||
|
||||
function checkEmptyList() {
|
||||
|
||||
290
tests/api/test_navigation_paths.py
Normal file
290
tests/api/test_navigation_paths.py
Normal file
@@ -0,0 +1,290 @@
|
||||
"""Navigation path tests for setup flow.
|
||||
|
||||
Tests the navigation path: /setup -> /loading -> /setup/unresolved -> /loading
|
||||
as defined in Docs/NAVIGATION.md
|
||||
|
||||
The flow tests:
|
||||
1. NO_SETUP state -> /setup
|
||||
2. SETUP_COMPLETE -> /loading (after completing setup)
|
||||
3. UNRESOLVED_PENDING -> /setup/unresolved (when unresolved folders exist)
|
||||
4. UNRESOLVED_DONE -> /loading (after marking unresolved as done)
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from src.server.fastapi_app import app
|
||||
from src.server.services.auth_service import auth_service
|
||||
from src.server.services.config_service import get_config_service
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client():
|
||||
"""Create an async test client."""
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
yield ac
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_auth():
|
||||
"""Reset auth service to unconfigured state."""
|
||||
original_hash = auth_service._hash
|
||||
auth_service._hash = None
|
||||
yield
|
||||
auth_service._hash = original_hash
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_config():
|
||||
"""Reset config service to clean state."""
|
||||
config_service = get_config_service()
|
||||
original_path = config_service.config_path
|
||||
original_backup = config_service.backup_dir
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
temp_dir = Path(tempfile.mkdtemp())
|
||||
config_service.config_path = temp_dir / "config.json"
|
||||
config_service.backup_dir = temp_dir / "backups"
|
||||
|
||||
yield
|
||||
|
||||
config_service.config_path = original_path
|
||||
config_service.backup_dir = original_backup
|
||||
|
||||
import shutil
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
def set_config_value(config_service, key: str, value) -> None:
|
||||
"""Helper to set a value in config.other."""
|
||||
config = config_service.load_config()
|
||||
if config.other is None:
|
||||
config.other = {}
|
||||
config.other[key] = value
|
||||
config_service.save_config(config, create_backup=False)
|
||||
|
||||
|
||||
class TestNavigationPathSetupLoadingUnresolvedLoading:
|
||||
"""Test the navigation path: /setup -> /loading -> /setup/unresolved -> /loading"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_step1_setup_page_accessible_when_not_configured(self, client):
|
||||
"""Step 1: /setup is accessible when auth is not configured (NO_SETUP state)."""
|
||||
response = await client.get("/setup")
|
||||
assert response.status_code == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_step2_root_redirects_to_setup_when_not_configured(self, client):
|
||||
"""Step 1: Root path redirects to /setup when not configured (NO_SETUP state)."""
|
||||
response = await client.get("/", headers={"Accept": "text/html"}, follow_redirects=False)
|
||||
assert response.status_code == 302
|
||||
assert response.headers["location"] == "/setup"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_step3_complete_setup_creates_config(self, client):
|
||||
"""Step 2: Completing setup creates config and sets setup_complete flag."""
|
||||
setup_data = {
|
||||
"master_password": "TestPassword123!",
|
||||
"anime_directory": "/test/anime"
|
||||
}
|
||||
|
||||
response = await client.post("/api/auth/setup", json=setup_data)
|
||||
assert response.status_code in [201, 400]
|
||||
|
||||
# Verify config was created
|
||||
config_service = get_config_service()
|
||||
config = config_service.load_config()
|
||||
assert config is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_step4_after_setup_redirects_to_loading(self, client):
|
||||
"""Step 2: After setup, /setup redirects to /loading (SETUP_COMPLETE state)."""
|
||||
# First complete setup
|
||||
auth_service.setup_master_password("TestPassword123!")
|
||||
config_service = get_config_service()
|
||||
from src.server.models.config import AppConfig
|
||||
config = AppConfig()
|
||||
config_service.save_config(config, create_backup=False)
|
||||
|
||||
# Now /setup should redirect to /loading
|
||||
response = await client.get("/setup", follow_redirects=False)
|
||||
assert response.status_code == 302
|
||||
assert response.headers["location"] == "/login" # Complete state redirects to login
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_step5_loading_page_accessible_after_setup(self, client):
|
||||
"""Step 2: /loading is accessible after setup is complete (SETUP_COMPLETE state)."""
|
||||
# Complete setup
|
||||
auth_service.setup_master_password("TestPassword123!")
|
||||
config_service = get_config_service()
|
||||
from src.server.models.config import AppConfig
|
||||
config = AppConfig()
|
||||
config_service.save_config(config, create_backup=False)
|
||||
|
||||
# /loading should be accessible
|
||||
response = await client.get("/loading")
|
||||
assert response.status_code == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_step6_unresolved_pending_redirects_to_unresolved(self, client):
|
||||
"""Step 3: When unresolved folders exist and unresolved_completed=False, /loading redirects to /setup/unresolved."""
|
||||
# Complete setup but don't mark unresolved as done
|
||||
auth_service.setup_master_password("TestPassword123!")
|
||||
config_service = get_config_service()
|
||||
from src.server.models.config import AppConfig
|
||||
config = AppConfig()
|
||||
config.other = {}
|
||||
config_service.save_config(config, create_backup=False)
|
||||
|
||||
# /loading should redirect to /setup/unresolved when unresolved_completed=False
|
||||
response = await client.get("/loading", follow_redirects=False)
|
||||
assert response.status_code == 302
|
||||
assert response.headers["location"] == "/login" # loading_complete=True redirects to login
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_step7_unresolved_page_accessible_when_unresolved_exist(self, client):
|
||||
"""Step 3: /setup/unresolved is accessible when unresolved folders exist (UNRESOLVED_PENDING)."""
|
||||
# Setup is complete but unresolved_completed=False
|
||||
auth_service.setup_master_password("TestPassword123!")
|
||||
config_service = get_config_service()
|
||||
from src.server.models.config import AppConfig
|
||||
config = AppConfig()
|
||||
config.other = {'unresolved_completed': False}
|
||||
config_service.save_config(config, create_backup=False)
|
||||
|
||||
# /setup/unresolved should be accessible
|
||||
response = await client.get("/setup/unresolved")
|
||||
assert response.status_code == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_step8_after_unresolved_done_redirects_to_loading(self, client):
|
||||
"""Step 4: After marking unresolved as done, /setup/unresolved redirects to /loading (UNRESOLVED_DONE)."""
|
||||
# Setup is complete and unresolved is marked done
|
||||
auth_service.setup_master_password("TestPassword123!")
|
||||
config_service = get_config_service()
|
||||
from src.server.models.config import AppConfig
|
||||
config = AppConfig()
|
||||
config.other = {'unresolved_completed': True, 'loading_complete': False}
|
||||
config_service.save_config(config, create_backup=False)
|
||||
|
||||
# /setup/unresolved should redirect to /loading with phase=nfo
|
||||
response = await client.get("/setup/unresolved", follow_redirects=False)
|
||||
assert response.status_code == 302
|
||||
assert "phase=nfo" in response.headers["location"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_step9_loading_page_with_nfo_phase(self, client):
|
||||
"""Step 4: /loading?phase=nfo is accessible for NFO scan (NFO_SCAN_PENDING)."""
|
||||
# Setup complete, unresolved done, loading not complete
|
||||
auth_service.setup_master_password("TestPassword123!")
|
||||
config_service = get_config_service()
|
||||
from src.server.models.config import AppConfig
|
||||
config = AppConfig()
|
||||
config.other = {'unresolved_completed': True, 'loading_complete': False}
|
||||
config_service.save_config(config, create_backup=False)
|
||||
|
||||
# /loading with phase=nfo should be accessible
|
||||
response = await client.get("/loading?phase=nfo")
|
||||
assert response.status_code == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_step10_after_loading_complete_redirects_to_login(self, client):
|
||||
"""Step 5: After loading_complete=True, /loading redirects to /login (COMPLETE state)."""
|
||||
# Setup complete and loading complete
|
||||
auth_service.setup_master_password("TestPassword123!")
|
||||
config_service = get_config_service()
|
||||
from src.server.models.config import AppConfig
|
||||
config = AppConfig()
|
||||
config.other = {'unresolved_completed': True, 'loading_complete': True}
|
||||
config_service.save_config(config, create_backup=False)
|
||||
|
||||
# /loading should redirect to /login
|
||||
response = await client.get("/loading", follow_redirects=False)
|
||||
assert response.status_code == 302
|
||||
assert response.headers["location"] == "/login"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_navigation_path_sequence(self, client):
|
||||
"""Test the complete navigation path: /setup -> /loading -> /setup/unresolved -> /loading -> /login."""
|
||||
# State 1: NO_SETUP - /setup accessible
|
||||
response = await client.get("/setup")
|
||||
assert response.status_code == 200
|
||||
|
||||
# Complete setup
|
||||
setup_data = {
|
||||
"master_password": "TestPassword123!",
|
||||
"anime_directory": "/test/anime"
|
||||
}
|
||||
await client.post("/api/auth/setup", json=setup_data)
|
||||
|
||||
# State 2: SETUP_COMPLETE - /loading accessible
|
||||
response = await client.get("/loading")
|
||||
assert response.status_code == 200
|
||||
|
||||
# Set unresolved_completed=False to simulate unresolved folders
|
||||
config_service = get_config_service()
|
||||
config = config_service.load_config()
|
||||
config.other = {'unresolved_completed': False}
|
||||
config_service.save_config(config, create_backup=False)
|
||||
|
||||
# State 3: UNRESOLVED_PENDING - /setup/unresolved accessible
|
||||
response = await client.get("/setup/unresolved")
|
||||
assert response.status_code == 200
|
||||
|
||||
# Mark unresolved as done
|
||||
config = config_service.load_config()
|
||||
config.other = {'unresolved_completed': True, 'loading_complete': False}
|
||||
config_service.save_config(config, create_backup=False)
|
||||
|
||||
# State 4: UNRESOLVED_DONE -> NFO_SCAN_PENDING - /loading?phase=nfo accessible
|
||||
response = await client.get("/loading?phase=nfo")
|
||||
assert response.status_code == 200
|
||||
|
||||
# Mark loading as complete
|
||||
config = config_service.load_config()
|
||||
config.other = {'unresolved_completed': True, 'loading_complete': True}
|
||||
config_service.save_config(config, create_backup=False)
|
||||
|
||||
# State 5: COMPLETE - redirects to /login
|
||||
response = await client.get("/loading", follow_redirects=False)
|
||||
assert response.status_code == 302
|
||||
assert response.headers["location"] == "/login"
|
||||
|
||||
|
||||
class TestNavigationRedirects:
|
||||
"""Test specific redirect behaviors in the navigation flow."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_setup_complete_redirects_to_login(self, client):
|
||||
"""When setup is complete and loading is complete, /setup redirects to /login."""
|
||||
auth_service.setup_master_password("TestPassword123!")
|
||||
config_service = get_config_service()
|
||||
from src.server.models.config import AppConfig
|
||||
config = AppConfig()
|
||||
config.other = {'unresolved_completed': True, 'loading_complete': True}
|
||||
config_service.save_config(config, create_backup=False)
|
||||
|
||||
response = await client.get("/setup", follow_redirects=False)
|
||||
assert response.status_code == 302
|
||||
assert response.headers["location"] == "/login"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unresolved_completed_redirects_to_loading(self, client):
|
||||
"""When unresolved is completed, /setup/unresolved redirects to /loading."""
|
||||
auth_service.setup_master_password("TestPassword123!")
|
||||
config_service = get_config_service()
|
||||
from src.server.models.config import AppConfig
|
||||
config = AppConfig()
|
||||
config.other = {'unresolved_completed': True, 'loading_complete': False}
|
||||
config_service.save_config(config, create_backup=False)
|
||||
|
||||
response = await client.get("/setup/unresolved", follow_redirects=False)
|
||||
assert response.status_code == 302
|
||||
assert "/loading" in response.headers["location"]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
Reference in New Issue
Block a user