Merge pull request #328 from JohnBaumb/feature/deep-linking
Add URL-based deep linking for SPA navigation
This commit is contained in:
commit
e0f036df08
3 changed files with 255 additions and 4 deletions
205
tests/test_spa_deep_linking.py
Normal file
205
tests/test_spa_deep_linking.py
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
"""Tests for the SPA deep-linking catch-all route.
|
||||
|
||||
The real web_server.py cannot be imported at test time (it initializes
|
||||
Spotify, Soulseek, Plex, etc.), so we replicate the routing behavior in a
|
||||
minimal Flask app that matches the real implementation verbatim.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from flask import Flask, abort
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# App factory — mirrors the routes added in web_server.py
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _build_app():
|
||||
app = Flask(__name__)
|
||||
app.testing = True
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
return 'INDEX_HTML', 200
|
||||
|
||||
@app.route('/<path:page>')
|
||||
def spa_catch_all(page):
|
||||
if page.startswith(('api/', 'static/', 'auth/', 'callback', 'deezer/', 'tidal/', 'status')):
|
||||
abort(404)
|
||||
return 'INDEX_HTML', 200
|
||||
|
||||
# Stand-ins for real routes so we can verify the catch-all does not shadow them
|
||||
@app.route('/api/ping')
|
||||
def api_ping():
|
||||
return {'ok': True}, 200
|
||||
|
||||
@app.route('/auth/spotify')
|
||||
def auth_spotify():
|
||||
return 'AUTH_SPOTIFY', 200
|
||||
|
||||
@app.route('/callback')
|
||||
def oauth_callback():
|
||||
return 'OAUTH_CALLBACK', 200
|
||||
|
||||
@app.route('/tidal/callback')
|
||||
def tidal_callback():
|
||||
return 'TIDAL_CALLBACK', 200
|
||||
|
||||
@app.route('/deezer/callback')
|
||||
def deezer_callback():
|
||||
return 'DEEZER_CALLBACK', 200
|
||||
|
||||
@app.route('/status')
|
||||
def status():
|
||||
return 'STATUS', 200
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
return _build_app().test_client()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Group A — SPA routes serve index.html
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSpaRoutes:
|
||||
"""Deep-link paths for valid client pages should serve index.html."""
|
||||
|
||||
@pytest.mark.parametrize("page", [
|
||||
'dashboard', 'sync', 'downloads', 'discover', 'artists',
|
||||
'automations', 'library', 'import', 'settings', 'help',
|
||||
'issues', 'stats', 'watchlist', 'wishlist', 'active-downloads',
|
||||
'artist-detail', 'playlist-explorer', 'hydrabase', 'tools',
|
||||
])
|
||||
def test_valid_page_serves_index(self, client, page):
|
||||
resp = client.get(f'/{page}')
|
||||
assert resp.status_code == 200
|
||||
assert resp.data == b'INDEX_HTML'
|
||||
|
||||
def test_root_still_serves_index(self, client):
|
||||
resp = client.get('/')
|
||||
assert resp.status_code == 200
|
||||
assert resp.data == b'INDEX_HTML'
|
||||
|
||||
def test_unknown_page_still_serves_index(self, client):
|
||||
# Standard SPA behavior — unknown paths fall through to the client router.
|
||||
resp = client.get('/this-page-does-not-exist')
|
||||
assert resp.status_code == 200
|
||||
assert resp.data == b'INDEX_HTML'
|
||||
|
||||
def test_nested_sub_path_serves_index(self, client):
|
||||
# Future-proofing: /artists/Linkin%20Park-style deep links.
|
||||
resp = client.get('/artists/Linkin%20Park')
|
||||
assert resp.status_code == 200
|
||||
assert resp.data == b'INDEX_HTML'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Group B — Reserved prefixes are not shadowed
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestReservedPrefixes:
|
||||
"""The catch-all must never swallow real API / auth / static routes."""
|
||||
|
||||
def test_real_api_route_wins(self, client):
|
||||
resp = client.get('/api/ping')
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json() == {'ok': True}
|
||||
|
||||
def test_unknown_api_path_returns_404(self, client):
|
||||
# Catch-all's abort(404) prevents /api/* from being answered with index.html.
|
||||
resp = client.get('/api/not-a-real-endpoint')
|
||||
assert resp.status_code == 404
|
||||
assert b'INDEX_HTML' not in resp.data
|
||||
|
||||
def test_unknown_static_path_returns_404(self, client):
|
||||
resp = client.get('/static/does-not-exist.js')
|
||||
assert resp.status_code == 404
|
||||
assert b'INDEX_HTML' not in resp.data
|
||||
|
||||
def test_unknown_auth_path_returns_404(self, client):
|
||||
resp = client.get('/auth/unknown-provider')
|
||||
assert resp.status_code == 404
|
||||
assert b'INDEX_HTML' not in resp.data
|
||||
|
||||
def test_real_auth_route_wins(self, client):
|
||||
resp = client.get('/auth/spotify')
|
||||
assert resp.status_code == 200
|
||||
assert resp.data == b'AUTH_SPOTIFY'
|
||||
|
||||
def test_real_callback_route_wins(self, client):
|
||||
resp = client.get('/callback')
|
||||
assert resp.status_code == 200
|
||||
assert resp.data == b'OAUTH_CALLBACK'
|
||||
|
||||
def test_callback_prefix_without_real_route_returns_404(self, client):
|
||||
# Anything starting with 'callback' is reserved even if Flask has no match.
|
||||
resp = client.get('/callback-fake')
|
||||
assert resp.status_code == 404
|
||||
assert b'INDEX_HTML' not in resp.data
|
||||
|
||||
def test_real_tidal_callback_wins(self, client):
|
||||
resp = client.get('/tidal/callback')
|
||||
assert resp.status_code == 200
|
||||
assert resp.data == b'TIDAL_CALLBACK'
|
||||
|
||||
def test_unknown_tidal_path_returns_404(self, client):
|
||||
resp = client.get('/tidal/other')
|
||||
assert resp.status_code == 404
|
||||
assert b'INDEX_HTML' not in resp.data
|
||||
|
||||
def test_real_deezer_callback_wins(self, client):
|
||||
resp = client.get('/deezer/callback')
|
||||
assert resp.status_code == 200
|
||||
assert resp.data == b'DEEZER_CALLBACK'
|
||||
|
||||
def test_unknown_deezer_path_returns_404(self, client):
|
||||
resp = client.get('/deezer/other')
|
||||
assert resp.status_code == 404
|
||||
assert b'INDEX_HTML' not in resp.data
|
||||
|
||||
def test_real_status_route_wins(self, client):
|
||||
resp = client.get('/status')
|
||||
assert resp.status_code == 200
|
||||
assert resp.data == b'STATUS'
|
||||
|
||||
def test_status_prefix_without_real_route_returns_404(self, client):
|
||||
resp = client.get('/status-extra')
|
||||
assert resp.status_code == 404
|
||||
assert b'INDEX_HTML' not in resp.data
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Group C — HTTP method restrictions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestHttpMethods:
|
||||
"""Catch-all should only respond to GET (Flask default)."""
|
||||
|
||||
def test_post_to_spa_path_not_allowed(self, client):
|
||||
resp = client.post('/discover')
|
||||
assert resp.status_code == 405
|
||||
|
||||
def test_put_to_spa_path_not_allowed(self, client):
|
||||
resp = client.put('/discover')
|
||||
assert resp.status_code == 405
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Group D — Query string and fragment handling
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestQueryStrings:
|
||||
"""Query strings must not affect routing decisions."""
|
||||
|
||||
def test_spa_path_with_query_string(self, client):
|
||||
resp = client.get('/discover?q=linkin+park')
|
||||
assert resp.status_code == 200
|
||||
assert resp.data == b'INDEX_HTML'
|
||||
|
||||
def test_setup_wizard_query_preserved_on_root(self, client):
|
||||
resp = client.get('/?setup=1')
|
||||
assert resp.status_code == 200
|
||||
assert resp.data == b'INDEX_HTML'
|
||||
|
|
@ -21,7 +21,7 @@ from pathlib import Path
|
|||
from urllib.parse import urljoin
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from flask import Flask, render_template, request, jsonify, redirect, send_file, Response, session, g
|
||||
from flask import Flask, render_template, request, jsonify, redirect, send_file, Response, session, g, abort
|
||||
from flask_socketio import SocketIO, emit, join_room, leave_room
|
||||
from utils.logging_config import get_logger, setup_logging
|
||||
from utils.async_helpers import run_async
|
||||
|
|
@ -4875,6 +4875,13 @@ def run_detection(server_type):
|
|||
def index():
|
||||
return render_template('index.html')
|
||||
|
||||
@app.route('/<path:page>')
|
||||
def spa_catch_all(page):
|
||||
# Serve index.html for client-side routes; let Flask handle real routes first.
|
||||
if page.startswith(('api/', 'static/', 'auth/', 'callback', 'deezer/', 'tidal/', 'status')):
|
||||
abort(404)
|
||||
return render_template('index.html')
|
||||
|
||||
# --- API Endpoints ---
|
||||
|
||||
# Tracks cumulative item-processed totals over time for windowed counting.
|
||||
|
|
|
|||
|
|
@ -2773,6 +2773,31 @@ function initializeNavigation() {
|
|||
navigateToPage(page);
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener('popstate', (event) => {
|
||||
const page = (event.state && event.state.page) || _getPageFromPath();
|
||||
if (page && page !== currentPage) {
|
||||
navigateToPage(page, { skipPushState: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const _DEEPLINK_VALID_PAGES = new Set([
|
||||
'dashboard', 'sync', 'downloads', 'discover', 'artists', 'automations',
|
||||
'library', 'import', 'settings', 'help', 'issues', 'stats', 'watchlist',
|
||||
'wishlist', 'active-downloads', 'artist-detail', 'playlist-explorer',
|
||||
'hydrabase', 'tools'
|
||||
]);
|
||||
|
||||
function _getPageFromPath() {
|
||||
const path = window.location.pathname.replace(/^\/+|\/+$/g, '');
|
||||
if (!path) return 'dashboard';
|
||||
const basePage = path.split('/')[0];
|
||||
if (!_DEEPLINK_VALID_PAGES.has(basePage)) return 'dashboard';
|
||||
// Context-dependent pages fall back to a sensible parent
|
||||
if (basePage === 'artist-detail') return 'artists';
|
||||
if (basePage === 'playlist-explorer') return 'library';
|
||||
return basePage;
|
||||
}
|
||||
|
||||
// ===============================
|
||||
|
|
@ -2905,7 +2930,7 @@ function initializeDownloadManagerToggle() {
|
|||
console.log('Download manager toggle initialized');
|
||||
}
|
||||
|
||||
function navigateToPage(pageId) {
|
||||
function navigateToPage(pageId, options = {}) {
|
||||
if (pageId === currentPage) return;
|
||||
|
||||
// Permission guard — redirect to home page if not allowed
|
||||
|
|
@ -2937,6 +2962,13 @@ function navigateToPage(pageId) {
|
|||
|
||||
currentPage = pageId;
|
||||
|
||||
if (!options.skipPushState) {
|
||||
const urlPath = pageId === 'dashboard' ? '/' : '/' + pageId;
|
||||
if (window.location.pathname !== urlPath) {
|
||||
history.pushState({ page: pageId }, '', urlPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Show/hide global search bar (hide on downloads page where enhanced search exists)
|
||||
if (typeof _gsUpdateVisibility === 'function') _gsUpdateVisibility();
|
||||
|
||||
|
|
@ -10233,8 +10265,15 @@ async function loadInitialData() {
|
|||
|
||||
// Navigate to user's home page (or dashboard for admin)
|
||||
const homePage = getProfileHomePage();
|
||||
if (homePage !== 'dashboard') {
|
||||
navigateToPage(homePage);
|
||||
const urlPage = _getPageFromPath();
|
||||
const targetPage = (urlPage && urlPage !== 'dashboard' && isPageAllowed(urlPage))
|
||||
? urlPage
|
||||
: homePage;
|
||||
|
||||
history.replaceState({ page: targetPage }, '', (targetPage === 'dashboard' ? '/' : '/' + targetPage) + window.location.search + window.location.hash);
|
||||
|
||||
if (targetPage !== 'dashboard') {
|
||||
navigateToPage(targetPage, { skipPushState: true });
|
||||
} else {
|
||||
await loadDashboardData();
|
||||
loadDashboardSyncHistory();
|
||||
|
|
|
|||
Loading…
Reference in a new issue