mirror of https://github.com/Nezreka/SoulSync.git
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.
398 lines
16 KiB
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()
|