6. Testing Flask routes
Chapter 18 shipped the web surface: Analytics, Playlist Manager form + AJAX, and Settings actions. Flask's app.test_client() exercises the real route functions, templates, sessions, and database helpers without binding to a port or making network calls.
Setting up the test client
Add two more fixtures to tests/conftest.py: one for the basic test client and one for an authenticated client with seeded session keys.
from unittest.mock import patch
from app import app as flask_app
@pytest.fixture
def app_client(seeded_db):
"""Flask test client with CSRF off and the in-memory db wired in."""
flask_app.config['TESTING'] = True
flask_app.config['WTF_CSRF_ENABLED'] = False
flask_app.secret_key = 'test_secret'
with patch('app.get_db_connection', return_value=seeded_db):
with flask_app.test_client() as client:
yield client
@pytest.fixture
def authenticated_client(app_client):
"""Test client with a seeded session that satisfies @require_auth."""
with app_client.session_transaction() as sess:
sess['access_token'] = 'AT_test_xxx'
sess['refresh_token'] = 'RT_test_xxx'
sess['token_expires_at'] = 9999999999
return app_client
@pytest.fixture
def csrf_enabled_client(seeded_db):
"""Companion fixture for the one explicit CSRF-on test."""
flask_app.config['TESTING'] = True
flask_app.config['WTF_CSRF_ENABLED'] = True
flask_app.secret_key = 'test_secret'
with patch('app.get_db_connection', return_value=seeded_db):
with flask_app.test_client() as client:
yield client
app_client is the default for most tests. authenticated_client wraps it with the session keys protected routes expect. csrf_enabled_client exists for one explicit protection test; the rest of the suite keeps CSRF disabled so each test can focus on the route contract it owns.
Test target: /analytics with ?range= fallback
The Analytics route silently falls back to the 12-month view for unknown ?range= values. Invalid input should not crash, and it should not return a 400.
def test_analytics_default_range_renders(authenticated_client):
response = authenticated_client.get('/analytics')
assert response.status_code == 200
assert b'analytics-grid' in response.data
def test_analytics_invalid_range_silently_coerces_to_12m(authenticated_client):
response = authenticated_client.get('/analytics?range=garbage')
assert response.status_code == 200
assert b'href="/analytics?range=12m" class="filter-btn active"' in response.data
Grouped route tests
The remaining route tests follow the same shape: send a request, assert the HTTP status, then inspect either the response, the session, or the in-memory database.
from unittest.mock import MagicMock, patch
def test_playlists_get_renders_source_form(authenticated_client):
"""GET /playlists -> 200 with the playlist-source form."""
response = authenticated_client.get('/playlists')
assert response.status_code == 200
assert b'name="source"' in response.data
def test_playlists_post_invalid_source_redirects_with_flash(authenticated_client):
"""POST /playlists with source='banana' -> 302 redirect."""
response = authenticated_client.post(
'/playlists', data={'source': 'banana'}, follow_redirects=False
)
assert response.status_code == 302
def test_settings_disconnect_clears_all_session_keys(authenticated_client):
with authenticated_client.session_transaction() as sess:
sess['oauth_state'] = 'state_xxx'
sess['next_url'] = '/analytics'
authenticated_client.post('/settings/disconnect', follow_redirects=False)
with authenticated_client.session_transaction() as sess:
for key in ('access_token', 'refresh_token', 'token_expires_at',
'oauth_state', 'next_url'):
assert key not in sess
def test_settings_clear_without_confirmation_does_not_delete(authenticated_client, seeded_db):
authenticated_client.post('/settings/clear', data={}, follow_redirects=False)
cursor = seeded_db.cursor()
cursor.execute("SELECT COUNT(*) FROM tracks")
assert cursor.fetchone()[0] == 5
def test_settings_clear_with_confirmation_deletes_all_data(authenticated_client, seeded_db):
authenticated_client.post(
'/settings/clear', data={'confirmed': 'true'}, follow_redirects=False
)
cursor = seeded_db.cursor()
for table in ('snapshots', 'tracks'):
cursor.execute(f"SELECT COUNT(*) FROM {table}")
assert cursor.fetchone()[0] == 0
def test_settings_export_returns_sqlite_download(authenticated_client, tmp_path):
fake_db = tmp_path / "music_time_machine.db"
fake_db.write_bytes(b"SQLite format 3\x00" + b"\x00" * 100)
with patch('app.DATABASE_PATH', str(fake_db)):
response = authenticated_client.get('/settings/export')
assert response.status_code == 200
assert response.mimetype == 'application/x-sqlite3'
assert 'attachment' in response.headers.get('Content-Disposition', '')
def test_settings_sync_runs_snapshot_script(authenticated_client):
completed = MagicMock(returncode=0, stdout="Created snapshot\n", stderr="")
with patch('app.subprocess.run', return_value=completed) as mock_run:
response = authenticated_client.post('/settings/sync', follow_redirects=False)
assert response.status_code == 302
assert response.headers['Location'].endswith('/settings')
mock_run.assert_called_once()
args, _kwargs = mock_run.call_args
assert any('monthly_snapshots.py' in arg for arg in args[0])
Test target: /api/generate-playlist
The AJAX endpoint deserves its own tests because the response shape is JSON, not HTML. Failure returns an HTTP status plus an {error} body, not a redirect with a flash message.
def test_api_generate_playlist_invalid_source_returns_400(authenticated_client):
response = authenticated_client.post(
'/api/generate-playlist',
json={'source': 'invalid'},
)
assert response.status_code == 400
body = response.get_json()
assert 'error' in body
assert 'success' not in body
def test_api_generate_playlist_no_tracks_returns_404(authenticated_client, in_memory_db):
with patch('app.get_db_connection', return_value=in_memory_db):
response = authenticated_client.post(
'/api/generate-playlist',
json={'source': 'current_rotation'},
)
assert response.status_code == 404
assert 'No tracks found' in response.get_json()['error']
The success path requires mocking the Spotify three-call sequence: current user, playlist creation, and track insertion. That is a good strengthening exercise once the failure paths are pinned.
Authentication tests
Two tests for the @require_auth decorator live in tests/test_auth.py because the auth contract is its own concern.
def test_unauthenticated_access_redirects_to_login(app_client):
response = app_client.get('/analytics', follow_redirects=False)
assert response.status_code == 302
assert '/login' in response.headers['Location']
with app_client.session_transaction() as sess:
assert sess.get('next_url') == '/analytics'
def test_authenticated_access_passes_through(authenticated_client):
response = authenticated_client.get('/analytics', follow_redirects=False)
assert response.status_code == 200
One CSRF-on test
The default fixtures turn CSRF off because forcing every test to fetch and submit a token would bloat the suite. One explicit test proves the protection layer rejects an unprotected POST.
def test_csrf_on_blocks_unprotected_post(csrf_enabled_client):
with csrf_enabled_client.session_transaction() as sess:
sess['access_token'] = 'AT_test_xxx'
response = csrf_enabled_client.post(
'/api/generate-playlist',
json={'source': 'current_rotation'},
)
assert response.status_code == 400
These tests cover route rendering, redirects, JSON errors, session mutation, destructive-action confirmation, file downloads, subprocess mocking, and CSRF protection. The next page handles time-dependent logic, coverage measurement, and CI.