173 lines
5.4 KiB
Dart
173 lines
5.4 KiB
Dart
@Timeout(Duration(seconds: 15))
|
|
library;
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:timbre/playback/playback_engine.dart';
|
|
import 'package:timbre/remote/discovery.dart';
|
|
import 'package:timbre/remote/host_server.dart';
|
|
import 'package:timbre/remote/messages.dart';
|
|
import 'package:timbre/remote/remote_session.dart';
|
|
import 'package:timbre/subsonic/models.dart';
|
|
|
|
/// End-to-end over a real loopback WebSocket: a [RemoteHost] driving a
|
|
/// (headless, audio-unsupported) [PlaybackController] and a [RemoteSession]
|
|
/// client in the same process. Exercises the handshake, HMAC auth, snapshot
|
|
/// broadcast, and command application — the whole stack minus the platform
|
|
/// audio backend and mDNS discovery.
|
|
void main() {
|
|
const serverKey = 'server-key';
|
|
const password = 'hunter2';
|
|
|
|
late PlaybackController controller;
|
|
late RemoteHost host;
|
|
final sessions = <RemoteSession>[];
|
|
|
|
PlaybackController buildController() => PlaybackController(
|
|
// Non-null so songs count as streamable; no real player exists on the
|
|
// test host (audio unsupported), so this URI is never opened.
|
|
streamUriFor: (s) => Uri.parse('http://host.local/stream/${s.id}'),
|
|
coverArtUriFor: (_) => null,
|
|
serverKeyGetter: () => serverKey,
|
|
onArt: (_) {},
|
|
onPlay: (_) {},
|
|
);
|
|
|
|
RemoteSession newSession(
|
|
int port, {
|
|
String key = serverKey,
|
|
String pass = password,
|
|
}) {
|
|
final s = RemoteSession(
|
|
device: DiscoveredDevice(
|
|
name: 'Host',
|
|
host: '127.0.0.1',
|
|
port: port,
|
|
serverKey: key,
|
|
version: kRemoteProtocolVersion,
|
|
),
|
|
serverKey: key,
|
|
password: pass,
|
|
deviceName: 'Remote',
|
|
);
|
|
sessions.add(s);
|
|
return s;
|
|
}
|
|
|
|
Song song(String id) => Song(id: id, title: 'Song $id');
|
|
|
|
// Subscribe to the "connected" event *before* dialing, so a fast handshake
|
|
// can't fire it before we're listening (broadcast streams drop unheard
|
|
// events).
|
|
Future<void> connectAuthed(RemoteSession s) async {
|
|
final connected =
|
|
s.status.firstWhere((x) => x == RemoteConnStatus.connected);
|
|
await s.connect();
|
|
await connected;
|
|
}
|
|
|
|
setUp(() async {
|
|
controller = buildController();
|
|
host = RemoteHost(
|
|
controller: controller,
|
|
serverKey: serverKey,
|
|
password: password,
|
|
deviceName: 'Host',
|
|
);
|
|
await host.start();
|
|
});
|
|
|
|
tearDown(() async {
|
|
for (final s in sessions) {
|
|
await s.close();
|
|
}
|
|
sessions.clear();
|
|
await host.stop();
|
|
controller.dispose();
|
|
});
|
|
|
|
test('valid remote completes the handshake and receives a snapshot',
|
|
() async {
|
|
final session = newSession(host.port!);
|
|
final firstSnapshot = session.snapshots.first;
|
|
await connectAuthed(session);
|
|
expect(session.hostDevice, 'Host');
|
|
await firstSnapshot; // initial state pushed on connect
|
|
});
|
|
|
|
test('host pushes a snapshot when local playback changes', () async {
|
|
final session = newSession(host.port!);
|
|
await connectAuthed(session);
|
|
|
|
final gotQueue =
|
|
session.snapshots.firstWhere((s) => s.queue.length == 2);
|
|
await controller.playSongs([song('a'), song('b')]);
|
|
|
|
final snap = await gotQueue;
|
|
expect(snap.queue.map((s) => s.id), ['a', 'b']);
|
|
expect(snap.currentIndex, 0);
|
|
});
|
|
|
|
test('a command from the remote drives the host engine', () async {
|
|
await controller.playSongs([song('a'), song('b'), song('c')]);
|
|
|
|
final session = newSession(host.port!);
|
|
await connectAuthed(session);
|
|
|
|
// Jump to index 2 from the remote; the host engine should follow, and a
|
|
// fresh snapshot should reflect it.
|
|
final jumped =
|
|
session.snapshots.firstWhere((s) => s.currentIndex == 2);
|
|
session.send(const RemoteCommand.jumpTo(2));
|
|
|
|
final snap = await jumped;
|
|
expect(snap.currentIndex, 2);
|
|
expect(controller.currentState.currentIndex, 2);
|
|
});
|
|
|
|
test('remote can enqueue a track by sending its Song', () async {
|
|
await controller.playSongs([song('a')]);
|
|
|
|
final session = newSession(host.port!);
|
|
await connectAuthed(session);
|
|
|
|
final grew = session.snapshots.firstWhere((s) => s.queue.length == 2);
|
|
session.send(RemoteCommand.addToQueue(song('z')));
|
|
|
|
final snap = await grew;
|
|
expect(snap.queue.map((s) => s.id), ['a', 'z']);
|
|
});
|
|
|
|
test('wrong password is denied', () async {
|
|
final session = newSession(host.port!, pass: 'wrong-password');
|
|
final denied =
|
|
session.status.firstWhere((s) => s == RemoteConnStatus.denied);
|
|
await session.connect();
|
|
await denied;
|
|
expect(host.clientCount, 0);
|
|
});
|
|
|
|
test('different server key is denied', () async {
|
|
final session = newSession(host.port!, key: 'other-server');
|
|
final denied =
|
|
session.status.firstWhere((s) => s == RemoteConnStatus.denied);
|
|
await session.connect();
|
|
await denied;
|
|
expect(host.clientCount, 0);
|
|
});
|
|
|
|
test('a host broadcast reaches every connected remote', () async {
|
|
final a = newSession(host.port!);
|
|
final b = newSession(host.port!);
|
|
await connectAuthed(a);
|
|
await connectAuthed(b);
|
|
expect(host.clientCount, 2);
|
|
|
|
// Subscribe both before the host changes, then drive from the host: both
|
|
// remotes must observe the new queue.
|
|
final aSees = a.snapshots.firstWhere((s) => s.queue.length == 1);
|
|
final bSees = b.snapshots.firstWhere((s) => s.queue.length == 1);
|
|
await controller.playSongs([song('a')]);
|
|
await aSees;
|
|
await bSees;
|
|
});
|
|
}
|