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.

tests/conftest.py (continued)
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.

tests/test_routes.py
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.

tests/test_routes.py (continued)
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.

tests/test_routes.py (continued)
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.

tests/test_auth.py
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.

tests/test_auth.py (continued)
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.