Fix CI/CD pipeline and code quality issues

## Major Changes
- Fixed all TypeScript errors in web client for successful compilation
- Resolved 82+ Python lint errors across backend services
- Updated Flutter SDK compatibility for mobile app
- Fixed security workflow configuration

## Web Client Fixes
- Fixed import path in DragonflyDashboard.vue (dragonflyApi import)
- All TypeScript compilation now passes without errors

## Backend Lint Fixes
- Updated type annotations to modern Python syntax (dict instead of Dict, X | None instead of Optional[X])
- Replaced try-except-pass with contextlib.suppress(Exception)
- Removed unused imports (Dict, Optional, Any, Iterator, etc.)
- Fixed bare except clauses to use Exception
- Sorted and formatted imports with ruff
- Applied ruff format to 27 files

## Workflow Fixes
- Updated Flutter SDK constraint from ^3.10.4 to ^3.5.0 (compatible with Flutter 3.24.0)
- Changed pip-audit format from github to json in security.yml
- Added comprehensive CI workflows (readiness-gate.yml, security.yml)

## Infrastructure
- Added DragonflyDB caching system integration
- Enhanced Docker configuration with multi-stage builds
- Added pytest configuration and test infrastructure
- Improved production readiness with proper error handling

## Verification
- backend-lint job:  Succeeded
- web job:  Succeeded
- Ready for GitHub deployment

All CI/CD issues resolved. Codebase now passes all quality checks.
This commit is contained in:
Tomas Dvorak
2026-03-21 10:01:14 +01:00
parent 07d2f71de5
commit cbf646e25b
208 changed files with 33414 additions and 11478 deletions
+1
View File
@@ -0,0 +1 @@
# SwingMusic Backend Test Suite
+233
View File
@@ -0,0 +1,233 @@
"""
Pytest fixtures for SwingMusic backend tests.
"""
import os
import pytest
import tempfile
import shutil
from pathlib import Path
from unittest.mock import patch, MagicMock
@pytest.fixture(scope="session")
def test_data_dir():
"""Create a temporary directory for test data."""
dir_path = tempfile.mkdtemp(prefix="swingmusic_test_")
yield Path(dir_path)
shutil.rmtree(dir_path, ignore_errors=True)
@pytest.fixture(scope="session")
def test_config_dir(test_data_dir):
"""Create a test configuration directory."""
config_dir = test_data_dir / "config"
config_dir.mkdir(exist_ok=True)
return config_dir
@pytest.fixture(scope="session")
def test_music_dir(test_data_dir):
"""Create a test music library directory with sample structure."""
music_dir = test_data_dir / "music"
music_dir.mkdir(exist_ok=True)
# Create sample artist/album structure
artist_dir = music_dir / "Test Artist"
artist_dir.mkdir(exist_ok=True)
album_dir = artist_dir / "Test Album"
album_dir.mkdir(exist_ok=True)
# Create a dummy audio file (empty for testing)
(album_dir / "01 Test Track.mp3").touch()
return music_dir
@pytest.fixture
def mock_env(test_data_dir, test_config_dir, test_music_dir):
"""Set up test environment variables."""
env_vars = {
"SWINGMUSIC_CONFIG_DIR": str(test_config_dir),
"SWINGMUSIC_MUSIC_DIR": str(test_music_dir),
"SWINGMUSIC_SECRET_KEY": "test-secret-key-for-integration-tests",
"SWINGMUSIC_JWT_SECRET": "test-jwt-secret-for-integration-tests",
"SWINGMUSIC_PAIR_CODE_TTL_SECONDS": "60",
"SWINGMUSIC_PAIR_CODE_MAX_ACTIVE": "10",
}
with patch.dict(os.environ, env_vars, clear=False):
yield env_vars
@pytest.fixture
def app(mock_env):
"""Create a test Flask application."""
# Import here to avoid circular imports and ensure env is set first
from swingmusic.app_builder import build
test_app = build()
test_app.config.update({
"TESTING": True,
"JWT_ACCESS_TOKEN_EXPIRES": 3600,
"JWT_REFRESH_TOKEN_EXPIRES": 86400,
})
yield test_app
@pytest.fixture
def client(app):
"""Create a test client for the Flask application."""
return app.test_client()
@pytest.fixture
def runner(app):
"""Create a test CLI runner for the Flask application."""
return app.test_cli_runner()
@pytest.fixture
def db_session(app):
"""Create a test database session."""
from swingmusic.db.userdata import UserTable
from swingmusic.db.production import get_engine
engine = get_engine()
yield engine
# Cleanup after each test
engine.dispose()
@pytest.fixture
def test_user_data():
"""Sample user data for testing."""
return {
"username": "testuser",
"password": "testpassword123",
"email": "[email protected]",
}
@pytest.fixture
def admin_user_data():
"""Sample admin user data for testing."""
return {
"username": "adminuser",
"password": "adminpassword123",
"email": "[email protected]",
"roles": ["admin", "user"],
}
@pytest.fixture
def auth_headers(client, test_user_data):
"""Get authentication headers for a test user."""
# First, bootstrap the owner if no users exist
response = client.get("/auth/bootstrap/status")
status = response.get_json()
if not status.get("setup_completed"):
# Bootstrap owner
client.post("/auth/bootstrap/owner", json={
"username": "owner",
"password": "ownerpassword123",
"root_dirs": [],
})
# Create a test user via invite
response = client.post("/auth/login", json={
"username": "owner",
"password": "ownerpassword123",
})
owner_token = response.get_json().get("accesstoken")
# Create invite
invite_response = client.post(
"/auth/invite/create",
json={"roles": ["user"]},
headers={"Authorization": f"Bearer {owner_token}"},
)
invite_token = invite_response.get_json().get("token")
# Accept invite with test user
client.post("/auth/invite/accept", json={
"token": invite_token,
"username": test_user_data["username"],
"password": test_user_data["password"],
})
# Login as test user
response = client.post("/auth/login", json={
"username": test_user_data["username"],
"password": test_user_data["password"],
})
token = response.get_json().get("accesstoken")
return {"Authorization": f"Bearer {token}"}
@pytest.fixture
def admin_auth_headers(client, admin_user_data):
"""Get authentication headers for an admin user."""
# Similar to auth_headers but with admin role
response = client.get("/auth/bootstrap/status")
status = response.get_json()
if not status.get("setup_completed"):
client.post("/auth/bootstrap/owner", json={
"username": "owner",
"password": "ownerpassword123",
"root_dirs": [],
})
response = client.post("/auth/login", json={
"username": "owner",
"password": "ownerpassword123",
})
owner_token = response.get_json().get("accesstoken")
invite_response = client.post(
"/auth/invite/create",
json={"roles": ["admin", "user"]},
headers={"Authorization": f"Bearer {owner_token}"},
)
invite_token = invite_response.get_json().get("token")
client.post("/auth/invite/accept", json={
"token": invite_token,
"username": admin_user_data["username"],
"password": admin_user_data["password"],
})
response = client.post("/auth/login", json={
"username": admin_user_data["username"],
"password": admin_user_data["password"],
})
token = response.get_json().get("accesstoken")
return {"Authorization": f"Bearer {token}"}
@pytest.fixture
def sample_trackhash():
"""Sample trackhash for testing."""
return "abc123def456"
@pytest.fixture
def sample_download_job():
"""Sample download job data for testing."""
return {
"source_url": "https://open.spotify.com/track/1234567890",
"source": "spotify",
"quality": "high",
"trackhash": "testhash123",
"title": "Test Track",
"artist": "Test Artist",
"album": "Test Album",
"item_type": "track",
}
+206
View File
@@ -0,0 +1,206 @@
"""
Authentication integration tests.
"""
import pytest
class TestBootstrap:
"""Tests for the bootstrap/owner setup flow."""
def test_bootstrap_status_initial(self, client):
"""Bootstrap status should show setup required when no users exist."""
response = client.get("/auth/bootstrap/status")
assert response.status_code == 200
data = response.get_json()
assert "setup_completed" in data
def test_bootstrap_owner_creates_first_user(self, client):
"""Bootstrap owner should create the first admin user."""
response = client.post("/auth/bootstrap/owner", json={
"username": "owner",
"password": "securepassword123",
"root_dirs": [],
})
# Should succeed (201) or fail if already exists (400)
assert response.status_code in [200, 201, 400]
if response.status_code in [200, 201]:
data = response.get_json()
assert "accesstoken" in data
assert "refreshtoken" in data
def test_bootstrap_owner_requires_username(self, client):
"""Bootstrap owner should require username."""
response = client.post("/auth/bootstrap/owner", json={
"password": "securepassword123",
})
assert response.status_code == 422 # Validation error
def test_bootstrap_owner_requires_password(self, client):
"""Bootstrap owner should require password."""
response = client.post("/auth/bootstrap/owner", json={
"username": "testowner",
})
assert response.status_code == 422 # Validation error
class TestLogin:
"""Tests for the login endpoint."""
def test_login_requires_username(self, client):
"""Login should require username."""
response = client.post("/auth/login", json={
"password": "testpassword",
})
assert response.status_code == 422
def test_login_requires_password(self, client):
"""Login should require password."""
response = client.post("/auth/login", json={
"username": "testuser",
})
assert response.status_code == 422
def test_login_nonexistent_user(self, client):
"""Login should fail for nonexistent user."""
response = client.post("/auth/login", json={
"username": "nonexistent_user_12345",
"password": "testpassword",
})
assert response.status_code == 404
def test_login_wrong_password(self, client):
"""Login should fail with wrong password."""
# First ensure owner exists
client.post("/auth/bootstrap/owner", json={
"username": "owner",
"password": "correctpassword123",
"root_dirs": [],
})
response = client.post("/auth/login", json={
"username": "owner",
"password": "wrongpassword",
})
assert response.status_code == 401
class TestPairCode:
"""Tests for the pairing code flow (mobile app login)."""
def test_get_pair_code_requires_auth(self, client):
"""Pair code generation should require authentication."""
response = client.get("/auth/getpaircode")
assert response.status_code in [401, 422] # Unauthorized or validation error
def test_pair_with_invalid_code(self, client):
"""Pairing with invalid code should fail."""
response = client.get("/auth/pair?code=INVALID")
assert response.status_code == 400
def test_pair_with_empty_code(self, client):
"""Pairing with empty code should fail."""
response = client.get("/auth/pair?code=")
assert response.status_code == 400
class TestInviteFlow:
"""Tests for the invite/accept user onboarding flow."""
def test_create_invite_requires_admin(self, client):
"""Create invite should require admin role."""
response = client.post("/auth/invite/create", json={
"roles": ["user"],
})
assert response.status_code in [401, 403]
def test_accept_invite_requires_token(self, client):
"""Accept invite should require a token."""
response = client.post("/auth/invite/accept", json={
"username": "newuser",
"password": "newpassword123",
})
assert response.status_code == 422
def test_accept_invite_invalid_token(self, client):
"""Accept invite should fail with invalid token."""
response = client.post("/auth/invite/accept", json={
"token": "invalid_token_12345",
"username": "newuser",
"password": "newpassword123",
})
assert response.status_code == 400
class TestTokenRefresh:
"""Tests for token refresh functionality."""
def test_refresh_requires_token(self, client):
"""Refresh should require a refresh token."""
response = client.post("/auth/refresh")
assert response.status_code in [401, 422]
def test_refresh_with_invalid_token(self, client):
"""Refresh should fail with invalid token."""
response = client.post(
"/auth/refresh",
headers={"Authorization": "Bearer invalid_token"},
)
assert response.status_code in [401, 422]
class TestAuthContract:
"""Contract tests for authentication API."""
def test_login_response_schema(self, client):
"""Login response should match expected schema."""
# Create owner first
client.post("/auth/bootstrap/owner", json={
"username": "contractowner",
"password": "contractpass123",
"root_dirs": [],
})
response = client.post("/auth/login", json={
"username": "contractowner",
"password": "contractpass123",
})
if response.status_code == 200:
data = response.get_json()
assert "accesstoken" in data
assert "refreshtoken" in data
assert "msg" in data
assert isinstance(data["accesstoken"], str)
assert isinstance(data["refreshtoken"], str)
def test_pair_code_response_schema(self, client):
"""Pair code response should match expected schema."""
# Create owner and login
client.post("/auth/bootstrap/owner", json={
"username": "paircodeowner",
"password": "paircodepass123",
"root_dirs": [],
})
login_response = client.post("/auth/login", json={
"username": "paircodeowner",
"password": "paircodepass123",
})
if login_response.status_code == 200:
token = login_response.get_json().get("accesstoken")
response = client.get(
"/auth/getpaircode",
headers={"Authorization": f"Bearer {token}"},
)
if response.status_code == 200:
data = response.get_json()
assert "code" in data
assert "expires_at" in data
assert "server_url" in data
assert isinstance(data["code"], str)
assert len(data["code"]) == 6 # 6-character code
+312
View File
@@ -0,0 +1,312 @@
"""
API contract tests - verify all endpoints match expected schemas.
"""
import pytest
class TestAuthContracts:
"""Contract tests for authentication endpoints."""
@pytest.mark.contract
def test_login_endpoint_exists(self, client):
"""Login endpoint should exist at /auth/login."""
response = client.post("/auth/login", json={})
# Should not be 404
assert response.status_code != 404
@pytest.mark.contract
def test_bootstrap_status_endpoint_exists(self, client):
"""Bootstrap status endpoint should exist."""
response = client.get("/auth/bootstrap/status")
assert response.status_code != 404
@pytest.mark.contract
def test_bootstrap_owner_endpoint_exists(self, client):
"""Bootstrap owner endpoint should exist."""
response = client.post("/auth/bootstrap/owner", json={})
assert response.status_code != 404
@pytest.mark.contract
def test_pair_code_endpoint_exists(self, client):
"""Pair code endpoint should exist."""
response = client.get("/auth/getpaircode")
# Should require auth, not 404
assert response.status_code != 404
@pytest.mark.contract
def test_pair_endpoint_exists(self, client):
"""Pair endpoint should exist."""
response = client.get("/auth/pair")
assert response.status_code != 404
@pytest.mark.contract
def test_refresh_endpoint_exists(self, client):
"""Refresh endpoint should exist."""
response = client.post("/auth/refresh")
assert response.status_code != 404
@pytest.mark.contract
def test_invite_create_endpoint_exists(self, client):
"""Invite create endpoint should exist."""
response = client.post("/auth/invite/create", json={})
assert response.status_code != 404
@pytest.mark.contract
def test_invite_accept_endpoint_exists(self, client):
"""Invite accept endpoint should exist."""
response = client.post("/auth/invite/accept", json={})
assert response.status_code != 404
class TestDownloadContracts:
"""Contract tests for download endpoints."""
@pytest.mark.contract
def test_jobs_endpoint_exists(self, client):
"""Jobs endpoint should exist."""
response = client.get("/api/downloads/jobs")
assert response.status_code != 404
@pytest.mark.contract
def test_create_job_endpoint_exists(self, client):
"""Create job endpoint should exist."""
response = client.post("/api/downloads/jobs", json={})
assert response.status_code != 404
@pytest.mark.contract
def test_queue_endpoint_exists(self, client):
"""Queue endpoint should exist."""
response = client.get("/api/downloads/queue")
assert response.status_code != 404
@pytest.mark.contract
def test_status_endpoint_exists(self, client):
"""Status endpoint should exist."""
response = client.get("/api/downloads/status")
assert response.status_code != 404
@pytest.mark.contract
def test_history_endpoint_exists(self, client):
"""History endpoint should exist."""
response = client.get("/api/downloads/history")
assert response.status_code != 404
@pytest.mark.contract
def test_import_candidates_endpoint_exists(self, client):
"""Import candidates endpoint should exist."""
response = client.post("/api/downloads/imports/candidates", json={})
assert response.status_code != 404
@pytest.mark.contract
def test_import_confirm_endpoint_exists(self, client):
"""Import confirm endpoint should exist."""
response = client.post("/api/downloads/imports/confirm", json={})
assert response.status_code != 404
class TestCatalogContracts:
"""Contract tests for catalog/search endpoints."""
@pytest.mark.contract
def test_search_endpoint_exists(self, client):
"""Search endpoint should exist."""
response = client.get("/api/catalog/search")
assert response.status_code != 404
@pytest.mark.contract
def test_tracks_endpoint_exists(self, client):
"""Tracks endpoint should exist."""
response = client.get("/api/catalog/tracks")
assert response.status_code != 404
@pytest.mark.contract
def test_albums_endpoint_exists(self, client):
"""Albums endpoint should exist."""
response = client.get("/api/catalog/albums")
assert response.status_code != 404
@pytest.mark.contract
def test_artists_endpoint_exists(self, client):
"""Artists endpoint should exist."""
response = client.get("/api/catalog/artists")
assert response.status_code != 404
@pytest.mark.contract
def test_folders_endpoint_exists(self, client):
"""Folders endpoint should exist."""
response = client.get("/api/catalog/folders")
assert response.status_code != 404
class TestFavoritesContracts:
"""Contract tests for favorites endpoints."""
@pytest.mark.contract
def test_favorite_tracks_endpoint_exists(self, client):
"""Favorite tracks endpoint should exist."""
response = client.get("/api/favorites/tracks")
assert response.status_code != 404
@pytest.mark.contract
def test_favorite_albums_endpoint_exists(self, client):
"""Favorite albums endpoint should exist."""
response = client.get("/api/favorites/albums")
assert response.status_code != 404
@pytest.mark.contract
def test_favorite_artists_endpoint_exists(self, client):
"""Favorite artists endpoint should exist."""
response = client.get("/api/favorites/artists")
assert response.status_code != 404
class TestPlaylistContracts:
"""Contract tests for playlist endpoints."""
@pytest.mark.contract
def test_playlists_endpoint_exists(self, client):
"""Playlists endpoint should exist."""
response = client.get("/api/playlists")
assert response.status_code != 404
@pytest.mark.contract
def test_create_playlist_endpoint_exists(self, client):
"""Create playlist endpoint should exist."""
response = client.post("/api/playlists", json={})
assert response.status_code != 404
class TestQueueContracts:
"""Contract tests for queue endpoints."""
@pytest.mark.contract
def test_queue_endpoint_exists(self, client):
"""Queue endpoint should exist."""
response = client.get("/api/queue")
assert response.status_code != 404
@pytest.mark.contract
def test_add_to_queue_endpoint_exists(self, client):
"""Add to queue endpoint should exist."""
response = client.post("/api/queue/add", json={})
assert response.status_code != 404
@pytest.mark.contract
def test_clear_queue_endpoint_exists(self, client):
"""Clear queue endpoint should exist."""
response = client.delete("/api/queue/clear")
assert response.status_code != 404
class TestSettingsContracts:
"""Contract tests for settings endpoints."""
@pytest.mark.contract
def test_settings_endpoint_exists(self, client):
"""Settings endpoint should exist."""
response = client.get("/api/settings")
assert response.status_code != 404
@pytest.mark.contract
def test_user_preferences_endpoint_exists(self, client):
"""User preferences endpoint should exist."""
response = client.get("/api/user/preferences")
assert response.status_code != 404
class TestLoggerContracts:
"""Contract tests for logger endpoints (used by mobile)."""
@pytest.mark.contract
def test_track_log_endpoint_exists(self, client):
"""Track log endpoint should exist for mobile playback tracking."""
response = client.post("/logger/track/log", json={})
assert response.status_code != 404
class TestMobileOfflineContracts:
"""Contract tests for mobile offline endpoints."""
@pytest.mark.contract
def test_device_register_endpoint_exists(self, client):
"""Device register endpoint should exist."""
response = client.post("/api/mobile-offline/devices/register", json={})
assert response.status_code != 404
@pytest.mark.contract
def test_devices_list_endpoint_exists(self, client):
"""Devices list endpoint should exist."""
response = client.get("/api/mobile-offline/devices")
assert response.status_code != 404
class TestResponseFormatContracts:
"""Contract tests for response formats."""
@pytest.mark.contract
def test_healthz_returns_json(self, client):
"""Health endpoint should return JSON."""
response = client.get("/healthz")
assert response.content_type.startswith("application/json")
@pytest.mark.contract
def test_bootstrap_status_returns_json(self, client):
"""Bootstrap status should return JSON."""
response = client.get("/auth/bootstrap/status")
assert response.content_type.startswith("application/json")
@pytest.mark.contract
def test_error_responses_are_json(self, client):
"""Error responses should be JSON."""
response = client.post("/auth/login", json={
"username": "nonexistent",
"password": "wrong",
})
assert response.content_type.startswith("application/json")
class TestCORSContracts:
"""Contract tests for CORS headers."""
@pytest.mark.contract
def test_cors_headers_on_health(self, client):
"""Health endpoint should have CORS headers."""
response = client.get("/healthz")
# CORS headers should be present for cross-origin requests
# At minimum, the response should succeed
assert response.status_code == 200
@pytest.mark.contract
def test_options_request_supported(self, client):
"""OPTIONS requests should be supported for CORS preflight."""
response = client.options("/auth/login")
# Should not be 404 or 405
assert response.status_code in [200, 204, 400]
class TestHTTPMethodContracts:
"""Contract tests for HTTP methods."""
@pytest.mark.contract
def test_login_accepts_post(self, client):
"""Login should accept POST."""
response = client.post("/auth/login", json={})
assert response.status_code != 405
@pytest.mark.contract
def test_healthz_accepts_get(self, client):
"""Health should accept GET."""
response = client.get("/healthz")
assert response.status_code != 405
@pytest.mark.contract
def test_jobs_accepts_get(self, client):
"""Jobs should accept GET."""
response = client.get("/api/downloads/jobs")
assert response.status_code != 405
@pytest.mark.contract
def test_create_job_accepts_post(self, client):
"""Create job should accept POST."""
response = client.post("/api/downloads/jobs", json={})
assert response.status_code != 405
+223
View File
@@ -0,0 +1,223 @@
"""
Download API integration tests.
"""
import pytest
class TestDownloadJobs:
"""Tests for download job management."""
def test_list_jobs_requires_auth(self, client):
"""List jobs should require authentication."""
response = client.get("/api/downloads/jobs")
assert response.status_code in [401, 423] # Unauthorized or setup incomplete
def test_create_job_requires_auth(self, client):
"""Create job should require authentication."""
response = client.post("/api/downloads/jobs", json={
"source_url": "https://open.spotify.com/track/123",
"source": "spotify",
"quality": "high",
})
assert response.status_code in [401, 423]
def test_get_queue_requires_auth(self, client):
"""Get queue should require authentication."""
response = client.get("/api/downloads/queue")
assert response.status_code in [401, 423]
def test_get_status_requires_auth(self, client):
"""Get status should require authentication."""
response = client.get("/api/downloads/status")
assert response.status_code in [401, 423]
class TestDownloadJobsWithAuth:
"""Tests for download jobs with authentication."""
def test_list_jobs_returns_empty_initially(self, client, auth_headers):
"""List jobs should return empty list for new user."""
response = client.get("/api/downloads/jobs", headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
assert "jobs" in data
assert "total" in data
assert isinstance(data["jobs"], list)
def test_create_job_validates_source(self, client, auth_headers):
"""Create job should accept valid source."""
response = client.post(
"/api/downloads/jobs",
headers=auth_headers,
json={
"source_url": "https://open.spotify.com/track/test123",
"source": "spotify",
"quality": "high",
"item_type": "track",
},
)
# Should create job (201) or fail gracefully
assert response.status_code in [200, 201, 400, 503]
if response.status_code in [200, 201]:
data = response.get_json()
assert "job_id" in data or "job" in data
def test_get_queue_returns_structure(self, client, auth_headers):
"""Get queue should return expected structure."""
response = client.get("/api/downloads/queue", headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
assert "queue" in data
assert "pending" in data
assert "active" in data
assert "history" in data
assert "queue_length" in data
assert "active_downloads" in data
def test_get_status_returns_counts(self, client, auth_headers):
"""Get status should return job counts by state."""
response = client.get("/api/downloads/status", headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
assert "counts" in data
assert "total" in data
counts = data["counts"]
assert "queued" in counts
assert "downloading" in counts
assert "completed" in counts
assert "failed" in counts
assert "cancelled" in counts
def test_get_history_returns_structure(self, client, auth_headers):
"""Get history should return paginated results."""
response = client.get("/api/downloads/history", headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
assert "history" in data
assert "total" in data
assert "limit" in data
assert "offset" in data
class TestDownloadJobOperations:
"""Tests for individual job operations."""
def test_get_nonexistent_job(self, client, auth_headers):
"""Get nonexistent job should return 404."""
response = client.get("/api/downloads/jobs/999999", headers=auth_headers)
assert response.status_code == 404
def test_cancel_nonexistent_job(self, client, auth_headers):
"""Cancel nonexistent job should fail."""
response = client.post("/api/downloads/jobs/999999/cancel", headers=auth_headers)
assert response.status_code in [400, 404]
def test_retry_nonexistent_job(self, client, auth_headers):
"""Retry nonexistent job should fail."""
response = client.post("/api/downloads/jobs/999999/retry", headers=auth_headers)
assert response.status_code in [400, 404]
class TestImportWorkflow:
"""Tests for the import workflow."""
def test_get_import_candidates_requires_auth(self, client):
"""Get import candidates should require authentication."""
response = client.post("/api/downloads/imports/candidates", json={
"trackhash": "testhash123",
})
assert response.status_code in [401, 423]
def test_confirm_import_requires_auth(self, client):
"""Confirm import should require authentication."""
response = client.post("/api/downloads/imports/confirm", json={
"trackhash": "testhash123",
})
assert response.status_code in [401, 423]
def test_get_import_candidates_returns_structure(self, client, auth_headers):
"""Get import candidates should return expected structure."""
response = client.post(
"/api/downloads/imports/candidates",
headers=auth_headers,
json={"trackhash": "nonexistent_hash_12345"},
)
assert response.status_code == 200
data = response.get_json()
assert "trackhash" in data
assert "availability" in data
assert "candidates" in data
class TestStorageRoots:
"""Tests for storage roots management."""
def test_get_storage_roots_requires_auth(self, client):
"""Get storage roots should require authentication."""
response = client.get("/api/downloads/storage/roots")
assert response.status_code in [401, 423]
def test_set_storage_roots_requires_auth(self, client):
"""Set storage roots should require authentication."""
response = client.post("/api/downloads/storage/roots", json={
"root_dirs": ["/home/user/music"],
})
assert response.status_code in [401, 423]
def test_get_storage_roots_returns_structure(self, client, auth_headers):
"""Get storage roots should return expected structure."""
response = client.get("/api/downloads/storage/roots", headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
# Should have root_dirs or similar
assert isinstance(data, dict)
class TestDownloadContract:
"""Contract tests for download API."""
def test_job_response_schema(self, client, auth_headers):
"""Job response should match expected schema."""
# Create a job
create_response = client.post(
"/api/downloads/jobs",
headers=auth_headers,
json={
"source_url": "https://open.spotify.com/track/contracttest",
"source": "spotify",
"quality": "high",
"item_type": "track",
"title": "Contract Test Track",
"artist": "Contract Test Artist",
},
)
if create_response.status_code in [200, 201]:
data = create_response.get_json()
# If job was created, verify structure
if "job" in data:
job = data["job"]
assert "id" in job or "job_id" in job
assert "state" in job
def test_queue_response_schema(self, client, auth_headers):
"""Queue response should match expected schema."""
response = client.get("/api/downloads/queue", headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
# Verify all required fields
required_fields = ["queue", "pending", "active", "history", "queue_length", "active_downloads"]
for field in required_fields:
assert field in data, f"Missing field: {field}"
+63
View File
@@ -0,0 +1,63 @@
"""
Health check integration tests.
"""
import pytest
class TestHealthEndpoint:
"""Tests for the /healthz endpoint."""
def test_healthz_returns_ok(self, client):
"""Health endpoint should return ok: true when server is running."""
response = client.get("/healthz")
assert response.status_code == 200
data = response.get_json()
assert data.get("ok") is True
def test_healthz_returns_setup_status(self, client):
"""Health endpoint should include setup completion status."""
response = client.get("/healthz")
assert response.status_code == 200
data = response.get_json()
assert "setup_completed" in data
assert "onboarding_required" in data
def test_healthz_returns_registered_modules(self, client):
"""Health endpoint should list registered API modules."""
response = client.get("/healthz")
assert response.status_code == 200
data = response.get_json()
assert "registered_modules" in data
assert "failed_modules" in data
def test_healthz_no_failed_modules(self, client):
"""Health endpoint should show no failed modules in normal operation."""
response = client.get("/healthz")
assert response.status_code == 200
data = response.get_json()
assert data.get("failed_modules") == []
class TestSetupStatus:
"""Tests for the /setup/status endpoint."""
def test_setup_status_returns_required(self, client):
"""Setup status should indicate if setup is required."""
response = client.get("/setup/status")
assert response.status_code == 200
data = response.get_json()
assert "required" in data
def test_setup_status_returns_version_info(self, client):
"""Setup status should include version information."""
response = client.get("/setup/status")
assert response.status_code == 200
data = response.get_json()
# Version info may be present
assert isinstance(data, dict)
+328
View File
@@ -0,0 +1,328 @@
"""
Mobile offline API integration tests.
"""
import pytest
class TestMobileDeviceRegistration:
"""Tests for mobile device registration."""
def test_register_device_requires_auth(self, client):
"""Register device should require authentication."""
response = client.post("/api/mobile-offline/devices/register", json={
"name": "Test Device",
"type": "phone",
})
assert response.status_code in [401, 423]
def test_get_devices_requires_auth(self, client):
"""Get devices should require authentication."""
response = client.get("/api/mobile-offline/devices")
assert response.status_code in [401, 423]
class TestMobileDeviceWithAuth:
"""Tests for mobile device operations with authentication."""
def test_register_device_creates_device(self, client, auth_headers):
"""Register device should create a new device entry."""
response = client.post(
"/api/mobile-offline/devices/register",
headers=auth_headers,
json={
"name": "Test Phone",
"type": "phone",
"device_id": "test-device-id-12345",
},
)
# Should succeed or return existing device
assert response.status_code in [200, 201, 400]
if response.status_code in [200, 201]:
data = response.get_json()
assert "device_id" in data or "id" in data
def test_get_devices_returns_list(self, client, auth_headers):
"""Get devices should return list of registered devices."""
response = client.get("/api/mobile-offline/devices", headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
assert "devices" in data or isinstance(data, list)
def test_register_device_with_storage_info(self, client, auth_headers):
"""Register device should accept storage information."""
response = client.post(
"/api/mobile-offline/devices/register",
headers=auth_headers,
json={
"name": "Test Tablet",
"type": "tablet",
"device_id": "test-tablet-id-12345",
"storage_capacity": 64000,
"available_storage": 32000,
},
)
assert response.status_code in [200, 201, 400]
class TestMobileOfflineLibrary:
"""Tests for mobile offline library management."""
def test_get_offline_library_requires_auth(self, client):
"""Get offline library should require authentication."""
response = client.get("/api/mobile-offline/devices/test-device/offline-library")
assert response.status_code in [401, 423]
def test_add_tracks_requires_auth(self, client):
"""Add tracks to offline should require authentication."""
response = client.post("/api/mobile-offline/devices/test-device/add-tracks", json={
"tracks": [],
})
assert response.status_code in [401, 423]
def test_remove_tracks_requires_auth(self, client):
"""Remove tracks from offline should require authentication."""
response = client.post("/api/mobile-offline/devices/test-device/remove-tracks", json={
"trackhashes": [],
})
assert response.status_code in [401, 423]
class TestMobileOfflineLibraryWithAuth:
"""Tests for offline library operations with authentication."""
def test_get_offline_library_for_device(self, client, auth_headers):
"""Get offline library should return library for registered device."""
# Register device first
client.post(
"/api/mobile-offline/devices/register",
headers=auth_headers,
json={
"name": "Library Test Device",
"type": "phone",
"device_id": "library-test-device-12345",
},
)
response = client.get(
"/api/mobile-offline/devices/library-test-device-12345/offline-library",
headers=auth_headers,
)
# Should return library or 404 if device not found
assert response.status_code in [200, 404]
if response.status_code == 200:
data = response.get_json()
assert isinstance(data, dict)
def test_add_tracks_to_offline(self, client, auth_headers):
"""Add tracks to offline library should work."""
# Register device first
client.post(
"/api/mobile-offline/devices/register",
headers=auth_headers,
json={
"name": "Add Tracks Device",
"type": "phone",
"device_id": "add-tracks-device-12345",
},
)
response = client.post(
"/api/mobile-offline/devices/add-tracks-device-12345/add-tracks",
headers=auth_headers,
json={
"tracks": [
{"trackhash": "testhash1", "title": "Test Track 1"},
{"trackhash": "testhash2", "title": "Test Track 2"},
],
"quality": "high",
},
)
assert response.status_code in [200, 201, 404]
def test_remove_tracks_from_offline(self, client, auth_headers):
"""Remove tracks from offline library should work."""
# Register device first
client.post(
"/api/mobile-offline/devices/register",
headers=auth_headers,
json={
"name": "Remove Tracks Device",
"type": "phone",
"device_id": "remove-tracks-device-12345",
},
)
response = client.post(
"/api/mobile-offline/devices/remove-tracks-device-12345/remove-tracks",
headers=auth_headers,
json={
"trackhashes": ["testhash1", "testhash2"],
},
)
assert response.status_code in [200, 404]
class TestMobileSyncOperations:
"""Tests for mobile sync operations."""
def test_sync_collection_requires_auth(self, client):
"""Sync collection should require authentication."""
response = client.post("/api/mobile-offline/devices/test-device/sync-collection", json={
"collection_type": "playlist",
"collection_id": "test123",
})
assert response.status_code in [401, 423]
def test_get_sync_progress_requires_auth(self, client):
"""Get sync progress should require authentication."""
response = client.get("/api/mobile-offline/devices/test-device/sync-progress")
assert response.status_code in [401, 423]
class TestMobileSyncWithAuth:
"""Tests for sync operations with authentication."""
def test_sync_collection_to_device(self, client, auth_headers):
"""Sync collection to device should initiate sync."""
# Register device first
client.post(
"/api/mobile-offline/devices/register",
headers=auth_headers,
json={
"name": "Sync Device",
"type": "phone",
"device_id": "sync-device-12345",
},
)
response = client.post(
"/api/mobile-offline/devices/sync-device-12345/sync-collection",
headers=auth_headers,
json={
"collection_type": "playlist",
"collection_id": "test-playlist-id",
"quality": "high",
},
)
assert response.status_code in [200, 201, 404, 400]
def test_get_sync_progress_for_device(self, client, auth_headers):
"""Get sync progress should return progress info."""
# Register device first
client.post(
"/api/mobile-offline/devices/register",
headers=auth_headers,
json={
"name": "Progress Device",
"type": "phone",
"device_id": "progress-device-12345",
},
)
response = client.get(
"/api/mobile-offline/devices/progress-device-12345/sync-progress",
headers=auth_headers,
)
assert response.status_code in [200, 404]
class TestMobileEvents:
"""Tests for mobile event batching."""
def test_push_events_requires_auth(self, client):
"""Push events should require authentication."""
response = client.post("/api/mobile-offline/devices/test-device/events/batch", json={
"events": [],
})
assert response.status_code in [401, 423]
def test_push_events_to_device(self, client, auth_headers):
"""Push events should batch upload events."""
# Register device first
client.post(
"/api/mobile-offline/devices/register",
headers=auth_headers,
json={
"name": "Events Device",
"type": "phone",
"device_id": "events-device-12345",
},
)
response = client.post(
"/api/mobile-offline/devices/events-device-12345/events/batch",
headers=auth_headers,
json={
"events": [
{
"type": "play",
"trackhash": "testhash123",
"timestamp": 1234567890,
"duration": 180,
},
{
"type": "favorite",
"trackhash": "testhash456",
"timestamp": 1234567891,
},
],
},
)
assert response.status_code in [200, 404]
class TestMobileOfflineContract:
"""Contract tests for mobile offline API."""
def test_device_registration_schema(self, client, auth_headers):
"""Device registration response should match expected schema."""
response = client.post(
"/api/mobile-offline/devices/register",
headers=auth_headers,
json={
"name": "Contract Test Device",
"type": "phone",
"device_id": "contract-device-12345",
"storage_capacity": 128000,
"available_storage": 64000,
},
)
if response.status_code in [200, 201]:
data = response.get_json()
# Should have device identifier
assert "device_id" in data or "id" in data
def test_offline_library_schema(self, client, auth_headers):
"""Offline library response should match expected schema."""
# Register device
client.post(
"/api/mobile-offline/devices/register",
headers=auth_headers,
json={
"name": "Schema Test Device",
"type": "phone",
"device_id": "schema-device-12345",
},
)
response = client.get(
"/api/mobile-offline/devices/schema-device-12345/offline-library",
headers=auth_headers,
)
if response.status_code == 200:
data = response.get_json()
# Library should be a dict with tracks or collections
assert isinstance(data, dict)