Add MusicWorkshop application
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from fastapi.testclient import TestClient
|
||||
except ModuleNotFoundError:
|
||||
TestClient = None
|
||||
|
||||
os.environ['MUSIC_WORKSHOP_DB_PATH'] = str(
|
||||
Path(tempfile.gettempdir()) / f'music_workshop_exception_api_{next(tempfile._get_candidate_names())}.db'
|
||||
)
|
||||
|
||||
try:
|
||||
from backend.app.exception_service import ExceptionItemNotFoundError
|
||||
from backend.app.schemas import (
|
||||
ExceptionDetailPayload,
|
||||
ExceptionListResponse,
|
||||
ExceptionSummaryPayload
|
||||
)
|
||||
import backend.app.main as main_module
|
||||
except ModuleNotFoundError as error:
|
||||
main_module = None
|
||||
ExceptionItemNotFoundError = None
|
||||
ExceptionDetailPayload = None
|
||||
ExceptionListResponse = None
|
||||
ExceptionSummaryPayload = None
|
||||
FASTAPI_IMPORT_ERROR = error
|
||||
else:
|
||||
FASTAPI_IMPORT_ERROR = None
|
||||
|
||||
|
||||
@unittest.skipIf(main_module is None, f'api deps unavailable: {FASTAPI_IMPORT_ERROR}')
|
||||
class ExceptionApiTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.previous_service = main_module.exception_service
|
||||
self.fake_service = _FakeExceptionService()
|
||||
main_module.exception_service = self.fake_service
|
||||
self.client = TestClient(main_module.app) if TestClient else None
|
||||
|
||||
def tearDown(self):
|
||||
main_module.exception_service = self.previous_service
|
||||
|
||||
def test_get_exception_summary_serializes_payload(self):
|
||||
response = main_module.get_exception_summary()
|
||||
payload = ExceptionSummaryPayload.model_validate(response)
|
||||
|
||||
self.assertEqual(payload.total, 6)
|
||||
self.assertEqual(payload.counts_by_type['duplicates'], 2)
|
||||
self.assertEqual(self.fake_service.summary_calls, 1)
|
||||
|
||||
def test_get_exception_items_passes_filters_and_pagination(self):
|
||||
response = main_module.get_exception_items(
|
||||
type='duplicates',
|
||||
resolution_status='resolved',
|
||||
page=2,
|
||||
page_size=25
|
||||
)
|
||||
payload = ExceptionListResponse.model_validate(response)
|
||||
|
||||
self.assertEqual(payload.page, 2)
|
||||
self.assertEqual(payload.page_size, 25)
|
||||
self.assertEqual(payload.total, 1)
|
||||
self.assertEqual(payload.items[0].exception_id, 101)
|
||||
self.assertEqual(payload.items[0].exception_type, 'duplicates')
|
||||
self.assertEqual(
|
||||
self.fake_service.list_calls,
|
||||
[{'type': 'duplicates', 'resolution_status': 'resolved', 'page': 2, 'page_size': 25}]
|
||||
)
|
||||
|
||||
def test_get_exception_item_serializes_detail_payload(self):
|
||||
response = main_module.get_exception_item(101)
|
||||
payload = ExceptionDetailPayload.model_validate(response)
|
||||
|
||||
self.assertEqual(payload.exception_id, 101)
|
||||
self.assertEqual(payload.filename, 'duplicate.flac')
|
||||
self.assertEqual(payload.dedupe_decision_json['comparison_scope'], 'library')
|
||||
self.assertEqual(self.fake_service.detail_calls, [101])
|
||||
|
||||
def test_get_exception_item_not_found_raises_service_error(self):
|
||||
with self.assertRaises(ExceptionItemNotFoundError):
|
||||
main_module.get_exception_item(999)
|
||||
|
||||
response = main_module.exception_item_not_found_error_handler(
|
||||
None,
|
||||
ExceptionItemNotFoundError(999)
|
||||
)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_streams_exception_audio_with_range_support(self):
|
||||
if self.client is None:
|
||||
self.skipTest('fastapi test client unavailable')
|
||||
audio_path = Path(tempfile.gettempdir()) / f'exception-audio-{next(tempfile._get_candidate_names())}.mp3'
|
||||
audio_path.write_bytes(b'0123456789abcdef')
|
||||
self.fake_service.audio_path = audio_path
|
||||
try:
|
||||
full_response = self.client.get('/api/exceptions/items/101/audio')
|
||||
self.assertEqual(full_response.status_code, 200)
|
||||
self.assertEqual(full_response.content, b'0123456789abcdef')
|
||||
self.assertEqual(full_response.headers['accept-ranges'], 'bytes')
|
||||
|
||||
range_response = self.client.get(
|
||||
'/api/exceptions/items/101/audio',
|
||||
headers={'Range': 'bytes=4-7'}
|
||||
)
|
||||
self.assertEqual(range_response.status_code, 206)
|
||||
self.assertEqual(range_response.content, b'4567')
|
||||
self.assertEqual(range_response.headers['content-range'], 'bytes 4-7/16')
|
||||
finally:
|
||||
if audio_path.exists():
|
||||
audio_path.unlink()
|
||||
|
||||
|
||||
class _FakeExceptionService:
|
||||
def __init__(self):
|
||||
self.summary_calls = 0
|
||||
self.list_calls: list[dict] = []
|
||||
self.detail_calls: list[int] = []
|
||||
self.audio_path: Path | None = None
|
||||
|
||||
def get_summary(self) -> dict:
|
||||
self.summary_calls += 1
|
||||
return {
|
||||
'total': 6,
|
||||
'counts_by_type': {
|
||||
'missing_tags': 1,
|
||||
'duplicates': 2,
|
||||
'match_failed': 1,
|
||||
'low_score': 1,
|
||||
'convert_failed': 0,
|
||||
'organize_failed': 1
|
||||
},
|
||||
'scanned_at': '2024-01-03T12:00:00Z'
|
||||
}
|
||||
|
||||
def get_items(
|
||||
self,
|
||||
exception_type: str = 'all',
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
resolution_status: str = 'open'
|
||||
) -> dict:
|
||||
self.list_calls.append(
|
||||
{
|
||||
'type': exception_type,
|
||||
'resolution_status': resolution_status,
|
||||
'page': page,
|
||||
'page_size': page_size
|
||||
}
|
||||
)
|
||||
return {
|
||||
'items': [self._detail_payload()],
|
||||
'page': page,
|
||||
'page_size': page_size,
|
||||
'total': 1
|
||||
}
|
||||
|
||||
def get_item(self, exception_id: int) -> dict:
|
||||
self.detail_calls.append(exception_id)
|
||||
if exception_id != 101:
|
||||
raise ExceptionItemNotFoundError(exception_id)
|
||||
return self._detail_payload()
|
||||
|
||||
def resolve_audio_path(self, exception_id: int) -> Path:
|
||||
self.detail_calls.append(exception_id)
|
||||
if exception_id != 101 or self.audio_path is None:
|
||||
raise FileNotFoundError(f'No playable audio found for exception item: {exception_id}')
|
||||
return self.audio_path
|
||||
|
||||
def _detail_payload(self) -> dict:
|
||||
return {
|
||||
'exception_id': 101,
|
||||
'task_id': 'task-123',
|
||||
'task_started_at': '2024-01-01T08:00:00Z',
|
||||
'exception_type': 'duplicates',
|
||||
'exception_stage': 'dedupe',
|
||||
'exception_reason_code': 'library_duplicate',
|
||||
'exception_message': '输出库中已存在重复文件,保留库内文件',
|
||||
'captured_at': '2024-01-03T12:00:00Z',
|
||||
'filename': 'duplicate.flac',
|
||||
'relative_path': 'Artist/Album/duplicate.flac',
|
||||
'original_path': '/tmp/input/duplicate.flac',
|
||||
'current_file_path': '/tmp/trash/duplicate.flac',
|
||||
'trash_file_path': '/tmp/trash/duplicate.flac',
|
||||
'audio_props_json': {'codec': 'FLAC'},
|
||||
'original_tags_json': {'title': 'Song'},
|
||||
'matched_metadata_json': {'title': 'Song'},
|
||||
'duplicate_of_path': '/tmp/output/Artist/Old.flac',
|
||||
'dedupe_decision_json': {'comparison_scope': 'library'},
|
||||
'library_relative_path': None,
|
||||
'library_file_path': None,
|
||||
'match_source': 'musicbrainz',
|
||||
'match_confidence': 91.2,
|
||||
'preview_available': False,
|
||||
'available_actions': [],
|
||||
'exception_resolution_status': 'open',
|
||||
'exception_resolution_json': None,
|
||||
'workflow_state': 'open',
|
||||
'raw_metadata': {'title': 'Song'},
|
||||
'metadata_draft': {'title': 'Song'},
|
||||
'effective_metadata': {'title': 'Song', 'artist': 'Artist', 'album_artist': 'Artist'},
|
||||
'can_ingest': True,
|
||||
'pending_ingest': False,
|
||||
'display_title': 'Song',
|
||||
'display_reason': '输出库中已存在重复文件,保留库内文件',
|
||||
'type_label': '文件重复',
|
||||
'preprocess_artifacts_json': None,
|
||||
'match_candidates_json': None,
|
||||
'match_enrichment_json': None,
|
||||
'organize_decision_json': None
|
||||
}
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user