You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
SoulSync/tests/test_phase1_websocket.py

398 lines
16 KiB

"""Phase 1 WebSocket migration tests.
Verifies that:
- WebSocket infrastructure connects and communicates
- HTTP endpoints still work (backward compat / fallback)
- Socket events deliver identical data to HTTP responses
- Download batch room subscriptions work correctly
IMPORTANT: Do NOT use ``from tests.conftest import …`` — pytest's auto-discovered
conftest is a different module instance. Use the ``shared_state`` fixture instead.
"""
import pytest
# =========================================================================
# Group A — Infrastructure
# =========================================================================
class TestInfrastructure:
"""Socket.IO connects, and HTTP endpoints remain functional."""
def test_socketio_connects(self, socketio_client):
"""Client can establish a WebSocket connection."""
assert socketio_client.is_connected()
def test_socketio_disconnect_and_reconnect(self, test_app):
"""Client can disconnect and reconnect cleanly."""
app, socketio = test_app
client = socketio.test_client(app)
assert client.is_connected()
client.disconnect()
assert not client.is_connected()
client.connect()
assert client.is_connected()
client.disconnect()
def test_http_status_still_works(self, flask_client):
"""GET /status returns 200 with expected keys."""
resp = flask_client.get('/status')
assert resp.status_code == 200
data = resp.get_json()
assert 'spotify' in data
assert 'media_server' in data
assert 'soulseek' in data
assert 'active_media_server' in data
def test_http_watchlist_count_still_works(self, flask_client):
"""GET /api/watchlist/count returns 200 with expected keys."""
resp = flask_client.get('/api/watchlist/count')
assert resp.status_code == 200
data = resp.get_json()
assert data['success'] is True
assert 'count' in data
assert 'next_run_in_seconds' in data
def test_http_download_batch_still_works(self, flask_client):
"""GET /api/download_status/batch returns 200 with expected structure."""
resp = flask_client.get('/api/download_status/batch')
assert resp.status_code == 200
data = resp.get_json()
assert 'batches' in data
assert 'metadata' in data
# =========================================================================
# Group B — Service Status Parity
# =========================================================================
class TestServiceStatus:
"""status:update socket events match GET /status HTTP responses."""
def test_status_update_received(self, test_app, shared_state):
"""Client receives a status:update event."""
app, socketio = test_app
client = socketio.test_client(app)
build = shared_state['build_status_payload']
socketio.emit('status:update', build())
received = client.get_received()
status_events = [e for e in received if e['name'] == 'status:update']
assert len(status_events) >= 1
def test_status_update_shape(self, test_app, shared_state):
"""status:update event data has the expected keys."""
app, socketio = test_app
client = socketio.test_client(app)
build = shared_state['build_status_payload']
socketio.emit('status:update', build())
received = client.get_received()
status_events = [e for e in received if e['name'] == 'status:update']
assert len(status_events) >= 1
data = status_events[0]['args'][0]
assert 'spotify' in data
assert 'media_server' in data
assert 'soulseek' in data
assert 'active_media_server' in data
def test_status_matches_http(self, test_app, shared_state):
"""Socket event data matches HTTP endpoint response exactly."""
app, socketio = test_app
flask_client = app.test_client()
ws_client = socketio.test_client(app)
build = shared_state['build_status_payload']
http_data = flask_client.get('/status').get_json()
socketio.emit('status:update', build())
received = ws_client.get_received()
status_events = [e for e in received if e['name'] == 'status:update']
assert len(status_events) >= 1
ws_data = status_events[0]['args'][0]
assert ws_data['spotify'] == http_data['spotify']
assert ws_data['media_server'] == http_data['media_server']
assert ws_data['soulseek'] == http_data['soulseek']
assert ws_data['active_media_server'] == http_data['active_media_server']
def test_status_reflects_cache_changes(self, test_app, shared_state):
"""When _status_cache changes, the next emit reflects it."""
app, socketio = test_app
client = socketio.test_client(app)
status_cache = shared_state['status_cache']
build = shared_state['build_status_payload']
# Mutate cache
status_cache['spotify']['source'] = 'itunes'
socketio.emit('status:update', build())
received = client.get_received()
status_events = [e for e in received if e['name'] == 'status:update']
data = status_events[-1]['args'][0]
assert data['spotify']['source'] == 'itunes'
# =========================================================================
# Group C — Watchlist Count Parity
# =========================================================================
class TestWatchlistCount:
"""watchlist:count socket events match GET /api/watchlist/count."""
def test_watchlist_count_received(self, test_app, shared_state):
"""Client receives a watchlist:count event."""
app, socketio = test_app
client = socketio.test_client(app)
build = shared_state['build_watchlist_count_payload']
socketio.emit('watchlist:count', build())
received = client.get_received()
wl_events = [e for e in received if e['name'] == 'watchlist:count']
assert len(wl_events) >= 1
def test_watchlist_count_shape(self, test_app, shared_state):
"""watchlist:count event data has expected keys."""
app, socketio = test_app
client = socketio.test_client(app)
build = shared_state['build_watchlist_count_payload']
socketio.emit('watchlist:count', build())
received = client.get_received()
wl_events = [e for e in received if e['name'] == 'watchlist:count']
data = wl_events[0]['args'][0]
assert data['success'] is True
assert isinstance(data['count'], int)
assert isinstance(data['next_run_in_seconds'], int)
def test_watchlist_matches_http(self, test_app, shared_state):
"""Socket event data matches HTTP endpoint response."""
app, socketio = test_app
flask_client = app.test_client()
ws_client = socketio.test_client(app)
build = shared_state['build_watchlist_count_payload']
http_data = flask_client.get('/api/watchlist/count').get_json()
socketio.emit('watchlist:count', build())
received = ws_client.get_received()
wl_events = [e for e in received if e['name'] == 'watchlist:count']
ws_data = wl_events[0]['args'][0]
assert ws_data['success'] == http_data['success']
assert ws_data['count'] == http_data['count']
assert ws_data['next_run_in_seconds'] == http_data['next_run_in_seconds']
def test_watchlist_reflects_count_change(self, test_app, shared_state):
"""When watchlist count changes, the emit reflects it."""
app, socketio = test_app
client = socketio.test_client(app)
wl_state = shared_state['watchlist_state']
build = shared_state['build_watchlist_count_payload']
wl_state['count'] = 42
socketio.emit('watchlist:count', build())
received = client.get_received()
wl_events = [e for e in received if e['name'] == 'watchlist:count']
data = wl_events[-1]['args'][0]
assert data['count'] == 42
# =========================================================================
# Group D — Download Batch Rooms
# =========================================================================
class TestDownloadBatch:
"""Download batch updates are delivered via room subscriptions."""
def _add_batch(self, shared_state, batch_id, **kwargs):
"""Helper to add a fake download batch."""
defaults = {
'phase': 'downloading',
'tasks': [
{'task_id': 't1', 'status': 'downloading', 'progress': 50},
{'task_id': 't2', 'status': 'searching', 'progress': 0},
],
'active_count': 2,
'max_concurrent': 3,
'playlist_id': 'spotify_test',
'playlist_name': 'Test Playlist',
}
defaults.update(kwargs)
batches = shared_state['download_batches']
lock = shared_state['tasks_lock']
with lock:
batches[batch_id] = defaults
def test_download_subscribe(self, test_app):
"""Client can subscribe to a batch room."""
app, socketio = test_app
client = socketio.test_client(app)
client.emit('downloads:subscribe', {'batch_ids': ['batch_abc']})
def test_download_receives_updates(self, test_app, shared_state):
"""After subscribing, client receives batch_update for that batch."""
app, socketio = test_app
client = socketio.test_client(app)
build_batch = shared_state['build_batch_status_data']
self._add_batch(shared_state, 'batch_123')
client.emit('downloads:subscribe', {'batch_ids': ['batch_123']})
client.get_received() # clear
batches = shared_state['download_batches']
lock = shared_state['tasks_lock']
with lock:
batch = batches['batch_123']
socketio.emit('downloads:batch_update', {
'batch_id': 'batch_123',
'data': build_batch('batch_123', batch),
}, room='batch:batch_123')
received = client.get_received()
dl_events = [e for e in received if e['name'] == 'downloads:batch_update']
assert len(dl_events) >= 1
payload = dl_events[0]['args'][0]
assert payload['batch_id'] == 'batch_123'
assert payload['data']['phase'] == 'downloading'
assert len(payload['data']['tasks']) == 2
def test_download_only_subscribed_batches(self, test_app, shared_state):
"""Client only receives updates for subscribed batches, not others."""
app, socketio = test_app
client = socketio.test_client(app)
build_batch = shared_state['build_batch_status_data']
self._add_batch(shared_state, 'batch_A')
self._add_batch(shared_state, 'batch_B')
client.emit('downloads:subscribe', {'batch_ids': ['batch_A']})
client.get_received() # clear
batches = shared_state['download_batches']
lock = shared_state['tasks_lock']
with lock:
for bid in ['batch_A', 'batch_B']:
socketio.emit('downloads:batch_update', {
'batch_id': bid,
'data': build_batch(bid, batches[bid]),
}, room=f'batch:{bid}')
received = client.get_received()
dl_events = [e for e in received if e['name'] == 'downloads:batch_update']
batch_ids_received = {e['args'][0]['batch_id'] for e in dl_events}
assert 'batch_A' in batch_ids_received
assert 'batch_B' not in batch_ids_received
def test_download_unsubscribe_stops_updates(self, test_app, shared_state):
"""After unsubscribing, client stops receiving updates for that batch."""
app, socketio = test_app
client = socketio.test_client(app)
build_batch = shared_state['build_batch_status_data']
self._add_batch(shared_state, 'batch_X')
client.emit('downloads:subscribe', {'batch_ids': ['batch_X']})
client.get_received() # clear
client.emit('downloads:unsubscribe', {'batch_ids': ['batch_X']})
client.get_received() # clear
batches = shared_state['download_batches']
lock = shared_state['tasks_lock']
with lock:
socketio.emit('downloads:batch_update', {
'batch_id': 'batch_X',
'data': build_batch('batch_X', batches['batch_X']),
}, room='batch:batch_X')
received = client.get_received()
dl_events = [e for e in received if e['name'] == 'downloads:batch_update']
assert len(dl_events) == 0
def test_download_batch_shape(self, test_app, shared_state):
"""Batch update data has the expected structure."""
app, socketio = test_app
client = socketio.test_client(app)
build_batch = shared_state['build_batch_status_data']
self._add_batch(shared_state, 'batch_shape')
client.emit('downloads:subscribe', {'batch_ids': ['batch_shape']})
client.get_received()
batches = shared_state['download_batches']
lock = shared_state['tasks_lock']
with lock:
socketio.emit('downloads:batch_update', {
'batch_id': 'batch_shape',
'data': build_batch('batch_shape', batches['batch_shape']),
}, room='batch:batch_shape')
received = client.get_received()
dl_events = [e for e in received if e['name'] == 'downloads:batch_update']
payload = dl_events[0]['args'][0]
assert 'batch_id' in payload
assert 'data' in payload
data = payload['data']
assert 'phase' in data
assert 'tasks' in data
assert 'active_count' in data
assert 'max_concurrent' in data
def test_download_http_batch_still_works(self, test_app, shared_state):
"""HTTP batch endpoint works alongside WebSocket rooms."""
app, socketio = test_app
flask_client = app.test_client()
self._add_batch(shared_state, 'batch_http')
resp = flask_client.get('/api/download_status/batch?batch_ids=batch_http')
assert resp.status_code == 200
data = resp.get_json()
assert 'batch_http' in data['batches']
assert data['batches']['batch_http']['phase'] == 'downloading'
# =========================================================================
# Group E — Fallback Behavior
# =========================================================================
class TestFallback:
"""HTTP endpoints work when no WebSocket is connected."""
def test_http_works_without_websocket(self, flask_client):
"""All three HTTP endpoints work without any WebSocket connection."""
# Status
resp = flask_client.get('/status')
assert resp.status_code == 200
data = resp.get_json()
assert data['spotify']['connected'] is True
# Watchlist
resp = flask_client.get('/api/watchlist/count')
assert resp.status_code == 200
data = resp.get_json()
assert data['count'] == 7
# Download batch (empty — no active batches)
resp = flask_client.get('/api/download_status/batch')
assert resp.status_code == 200
data = resp.get_json()
assert data['batches'] == {}
def test_multiple_clients_get_updates(self, test_app, shared_state):
"""Multiple WebSocket clients each receive broadcast events."""
app, socketio = test_app
client1 = socketio.test_client(app)
client2 = socketio.test_client(app)
build = shared_state['build_status_payload']
socketio.emit('status:update', build())
for client in [client1, client2]:
received = client.get_received()
status_events = [e for e in received if e['name'] == 'status:update']
assert len(status_events) >= 1
client1.disconnect()
client2.disconnect()