diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..53368aa --- /dev/null +++ b/Makefile @@ -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!"}' \ No newline at end of file diff --git a/src/server/api/auth.py b/src/server/api/auth.py index ade72b5..bf8deff 100644 --- a/src/server/api/auth.py +++ b/src/server/api/auth.py @@ -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, diff --git a/src/server/web/templates/unresolved.html b/src/server/web/templates/unresolved.html index 626ce18..c52fd5f 100644 --- a/src/server/web/templates/unresolved.html +++ b/src/server/web/templates/unresolved.html @@ -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 = ''; + + 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 = ' 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 = ''; - - 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() { diff --git a/tests/api/test_navigation_paths.py b/tests/api/test_navigation_paths.py new file mode 100644 index 0000000..fb181d4 --- /dev/null +++ b/tests/api/test_navigation_paths.py @@ -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"]) \ No newline at end of file