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:
2026-06-07 21:51:49 +02:00
parent 14f7b2f28a
commit e0be00dce6
4 changed files with 368 additions and 54 deletions

21
Makefile Normal file
View 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!"}'

View File

@@ -16,6 +16,7 @@ from src.server.models.auth import (
from src.server.models.config import AppConfig from src.server.models.config import AppConfig
from src.server.services.auth_service import AuthError, LockedOutError, auth_service 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.config_service import get_config_service
from src.server.services.progress_service import ProgressType
logger = structlog.get_logger(__name__) logger = structlog.get_logger(__name__)
@@ -194,7 +195,6 @@ async def setup_auth(req: SetupRequest):
) )
except Exception as e: except Exception as e:
# Send error event # Send error event
from src.server.services.progress_service import ProgressType
await progress_service.start_progress( await progress_service.start_progress(
progress_id="initialization_error", progress_id="initialization_error",
progress_type=ProgressType.ERROR, progress_type=ProgressType.ERROR,

View File

@@ -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() { function attachFolderEvents() {
// Input enable/disable resolve button // Input enable/disable resolve button
document.querySelectorAll('.folder-input').forEach(input => { document.querySelectorAll('.folder-input').forEach(input => {
@@ -779,6 +833,7 @@
// Keep search row visible for additional searches // Keep search row visible for additional searches
btn.classList.remove('searching'); btn.classList.remove('searching');
btn.innerHTML = '<i class="fas fa-search"></i> Search Again'; btn.innerHTML = '<i class="fas fa-search"></i> Search Again';
attachSuggestionLinkEvents();
} catch (err) { } catch (err) {
showToast('Search failed', 'error'); showToast('Search failed', 'error');
btn.classList.remove('searching'); btn.classList.remove('searching');
@@ -790,59 +845,7 @@
}); });
// Suggestion link click - populate input and resolve // Suggestion link click - populate input and resolve
document.querySelectorAll('.suggestion-link').forEach(link => { attachSuggestionLinkEvents();
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');
}
});
});
} }
function checkEmptyList() { function checkEmptyList() {

View 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"])