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