fix(relay): secure reconnect and room ownership
This commit is contained in:
@@ -16,9 +16,11 @@ class _Harness {
|
||||
FakeAsync async, {
|
||||
ControlMode controlMode = ControlMode.hostOnly,
|
||||
HostCoordinatorCallbacks callbacks = const HostCoordinatorCallbacks(),
|
||||
Duration duration = const Duration(minutes: 45),
|
||||
bool seekable = true,
|
||||
}) {
|
||||
int nowMs() => _epochMs + async.elapsed.inMilliseconds;
|
||||
player = FakeSyncPlayer(position: const Duration(minutes: 2));
|
||||
player = FakeSyncPlayer(position: const Duration(minutes: 2), duration: duration, seekable: seekable);
|
||||
coordinator = HostPlaybackCoordinator(
|
||||
myPeerId: 'host',
|
||||
controlMode: controlMode,
|
||||
@@ -446,6 +448,132 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
test('invalid remote seeks and rates are rejected without side effects', () {
|
||||
fakeAsync((async) {
|
||||
final actions = <(String, PlaybackActionHint)>[];
|
||||
final h = _Harness(
|
||||
async,
|
||||
controlMode: ControlMode.anyone,
|
||||
callbacks: HostCoordinatorCallbacks(onRemoteAction: (peer, hint) => actions.add((peer, hint))),
|
||||
);
|
||||
h.attachForMedia(async);
|
||||
h.hostBecomesReady(async);
|
||||
final durationMs = h.player.state.duration.inMilliseconds;
|
||||
final broadcastsBefore = h.broadcasts.length;
|
||||
final seqBefore = h.last.seq;
|
||||
final anchorBefore = h.last.anchorPositionMs;
|
||||
final rateBefore = h.last.rate;
|
||||
h.player.commandLog.clear();
|
||||
|
||||
for (final targetMs in [-1, durationMs + 1]) {
|
||||
h.coordinator.onControlRequest('guest', ControlRequest(kind: ControlRequestKind.seek, positionMs: targetMs));
|
||||
}
|
||||
for (final rate in [0.25 - 0.000001, 4.0 + 0.000001, double.nan, double.infinity, double.negativeInfinity]) {
|
||||
h.coordinator.onControlRequest('guest', ControlRequest(kind: ControlRequestKind.rate, rate: rate));
|
||||
}
|
||||
async.flushMicrotasks();
|
||||
|
||||
expect(h.player.commandLog, isEmpty);
|
||||
expect(actions, isEmpty);
|
||||
expect(h.broadcasts, hasLength(broadcastsBefore));
|
||||
expect(h.last.seq, seqBefore);
|
||||
expect(h.last.anchorPositionMs, anchorBefore);
|
||||
expect(h.last.rate, rateBefore);
|
||||
h.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
test('inclusive remote seek and rate boundaries are applied exactly', () {
|
||||
fakeAsync((async) {
|
||||
final actions = <(String, PlaybackActionHint)>[];
|
||||
final h = _Harness(
|
||||
async,
|
||||
controlMode: ControlMode.anyone,
|
||||
callbacks: HostCoordinatorCallbacks(onRemoteAction: (peer, hint) => actions.add((peer, hint))),
|
||||
);
|
||||
h.attachForMedia(async);
|
||||
h.hostBecomesReady(async);
|
||||
final durationMs = h.player.state.duration.inMilliseconds;
|
||||
final seqBefore = h.last.seq;
|
||||
h.player.commandLog.clear();
|
||||
|
||||
for (final targetMs in [0, durationMs]) {
|
||||
h.coordinator.onControlRequest('guest', ControlRequest(kind: ControlRequestKind.seek, positionMs: targetMs));
|
||||
async.flushMicrotasks();
|
||||
expect(h.last.anchorPositionMs, targetMs);
|
||||
expect(h.last.actorPeerId, 'guest');
|
||||
expect(h.last.actionHint, PlaybackActionHint.seek);
|
||||
}
|
||||
for (final rate in [0.25, 4.0]) {
|
||||
h.coordinator.onControlRequest('guest', ControlRequest(kind: ControlRequestKind.rate, rate: rate));
|
||||
async.flushMicrotasks();
|
||||
expect(h.last.rate, rate);
|
||||
expect(h.last.actorPeerId, 'guest');
|
||||
expect(h.last.actionHint, PlaybackActionHint.rate);
|
||||
}
|
||||
|
||||
expect(h.player.commandLog, ['seek:0', 'seek:$durationMs', 'rate:0.25', 'rate:4.0']);
|
||||
expect(h.last.seq, seqBefore + 4);
|
||||
expect(actions, [
|
||||
('guest', PlaybackActionHint.seek),
|
||||
('guest', PlaybackActionHint.seek),
|
||||
('guest', PlaybackActionHint.rate),
|
||||
('guest', PlaybackActionHint.rate),
|
||||
]);
|
||||
h.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
test('remote seeks require seekability and a positive known duration', () {
|
||||
fakeAsync((async) {
|
||||
for (final config in [
|
||||
(seekable: false, duration: const Duration(minutes: 45)),
|
||||
(seekable: true, duration: Duration.zero),
|
||||
]) {
|
||||
final actions = <(String, PlaybackActionHint)>[];
|
||||
final h = _Harness(
|
||||
async,
|
||||
controlMode: ControlMode.anyone,
|
||||
duration: config.duration,
|
||||
seekable: config.seekable,
|
||||
callbacks: HostCoordinatorCallbacks(onRemoteAction: (peer, hint) => actions.add((peer, hint))),
|
||||
);
|
||||
h.attachForMedia(async);
|
||||
h.hostBecomesReady(async);
|
||||
async.elapse(Duration(milliseconds: h.last.anchorHostTimeMs - (_epochMs + async.elapsed.inMilliseconds)));
|
||||
h.player.commandLog.clear();
|
||||
actions.clear();
|
||||
final broadcastsBefore = h.broadcasts.length;
|
||||
final seqBefore = h.last.seq;
|
||||
|
||||
h.coordinator.onControlRequest(
|
||||
'guest',
|
||||
const ControlRequest(kind: ControlRequestKind.seek, positionMs: 1000),
|
||||
);
|
||||
async.flushMicrotasks();
|
||||
|
||||
expect(h.player.commandLog, isEmpty);
|
||||
expect(actions, isEmpty);
|
||||
expect(h.broadcasts, hasLength(broadcastsBefore));
|
||||
expect(h.last.seq, seqBefore);
|
||||
|
||||
h.coordinator.onControlRequest('guest', const ControlRequest(kind: ControlRequestKind.rate, rate: 0.25));
|
||||
h.coordinator.onControlRequest('guest', const ControlRequest(kind: ControlRequestKind.pause));
|
||||
h.coordinator.onControlRequest('guest', const ControlRequest(kind: ControlRequestKind.play));
|
||||
async.flushMicrotasks();
|
||||
async.elapse(Duration(milliseconds: h.last.anchorHostTimeMs - (_epochMs + async.elapsed.inMilliseconds)));
|
||||
|
||||
expect(h.player.commandLog, ['rate:0.25', 'pause', 'play']);
|
||||
expect(actions, [
|
||||
('guest', PlaybackActionHint.rate),
|
||||
('guest', PlaybackActionHint.pause),
|
||||
('guest', PlaybackActionHint.play),
|
||||
]);
|
||||
h.dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('local seeks debounce into a single re-anchor broadcast', () {
|
||||
fakeAsync((async) {
|
||||
final h = _Harness(async);
|
||||
|
||||
@@ -112,10 +112,11 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('SyncMessage v2 envelope', () {
|
||||
test('join carries the protocol version', () {
|
||||
group('SyncMessage v3 envelope', () {
|
||||
test('join carries sync protocol 3', () {
|
||||
final join = SyncMessage.join(peerId: 'p', displayName: 'Name', isHost: false);
|
||||
final decoded = SyncMessage.fromJson(join.toJson());
|
||||
expect(decoded.version, 3);
|
||||
expect(decoded.version, SyncMessage.protocolVersion);
|
||||
});
|
||||
|
||||
@@ -125,7 +126,7 @@ void main() {
|
||||
expect(decoded.peerId, 'p');
|
||||
});
|
||||
|
||||
test('copyWith preserves v2 payloads', () {
|
||||
test('copyWith preserves v3 payloads', () {
|
||||
final relabeled = SyncMessage.state(fullState).copyWith(peerId: 'relay-id');
|
||||
expect(relabeled.state, fullState);
|
||||
expect(relabeled.peerId, 'relay-id');
|
||||
|
||||
@@ -2,12 +2,6 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/watch_together/primitives.dart';
|
||||
|
||||
void main() {
|
||||
test('stored room codes preserve the established host peer wire format', () {
|
||||
const persistedSessionId = 'Ab12z';
|
||||
|
||||
expect(watchTogetherHostPeerId(persistedSessionId), 'wt-AB12Z');
|
||||
});
|
||||
|
||||
test('orderedStringListsEqual preserves order and multiplicity', () {
|
||||
expect(orderedStringListsEqual(const ['a', 'b'], const ['a', 'b']), isTrue);
|
||||
expect(orderedStringListsEqual(const ['a'], const ['a', 'b']), isFalse);
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/services/settings_service.dart';
|
||||
import 'package:plezy/watch_together/models/watch_session.dart';
|
||||
import 'package:plezy/watch_together/services/recent_rooms_service.dart';
|
||||
import 'package:plezy/watch_together/services/watch_together_relay_endpoint.dart';
|
||||
|
||||
import '../test_helpers/prefs.dart';
|
||||
|
||||
void main() {
|
||||
late SettingsService settings;
|
||||
final endpointA = WatchTogetherRelayEndpoint.resolve('https://relay-a.example.test/base/');
|
||||
final endpointB = WatchTogetherRelayEndpoint.resolve('http://relay-b.example.test:8080');
|
||||
|
||||
setUp(() async {
|
||||
resetSharedPreferencesForTest();
|
||||
SettingsService.resetForTesting();
|
||||
settings = await SettingsService.getInstance();
|
||||
});
|
||||
|
||||
test('rooms are isolated by profile', () async {
|
||||
await RecentRoomsService.addOrUpdateRoom(
|
||||
'ROOM1',
|
||||
profileId: 'profile-a',
|
||||
endpoint: endpointA,
|
||||
name: 'Profile A room',
|
||||
controlMode: ControlMode.hostOnly,
|
||||
);
|
||||
|
||||
expect(RecentRoomsService.getRecentRooms(profileId: 'profile-b', endpoint: endpointA), isEmpty);
|
||||
|
||||
await RecentRoomsService.addOrUpdateRoom(
|
||||
'ROOM1',
|
||||
profileId: 'profile-b',
|
||||
endpoint: endpointA,
|
||||
name: 'Profile B room',
|
||||
controlMode: ControlMode.anyone,
|
||||
);
|
||||
|
||||
final profileA = RecentRoomsService.getRecentRooms(profileId: 'profile-a', endpoint: endpointA);
|
||||
final profileB = RecentRoomsService.getRecentRooms(profileId: 'profile-b', endpoint: endpointA);
|
||||
expect(profileA.single.name, 'Profile A room');
|
||||
expect(profileA.single.controlMode, ControlMode.hostOnly);
|
||||
expect(profileB.single.name, 'Profile B room');
|
||||
expect(profileB.single.controlMode, ControlMode.anyone);
|
||||
});
|
||||
|
||||
test('same code is independently mutable on different relay bases', () async {
|
||||
await RecentRoomsService.addOrUpdateRoom(
|
||||
'SAME1',
|
||||
profileId: 'profile-a',
|
||||
endpoint: endpointA,
|
||||
name: 'Relay A',
|
||||
controlMode: ControlMode.hostOnly,
|
||||
);
|
||||
await RecentRoomsService.addOrUpdateRoom(
|
||||
'SAME1',
|
||||
profileId: 'profile-a',
|
||||
endpoint: endpointB,
|
||||
name: 'Relay B',
|
||||
controlMode: ControlMode.anyone,
|
||||
);
|
||||
|
||||
await RecentRoomsService.renameRoom('SAME1', 'Renamed A', profileId: 'profile-a', endpoint: endpointA);
|
||||
expect(RecentRoomsService.getRecentRooms(profileId: 'profile-a', endpoint: endpointA).single.name, 'Renamed A');
|
||||
expect(RecentRoomsService.getRecentRooms(profileId: 'profile-a', endpoint: endpointB).single.name, 'Relay B');
|
||||
|
||||
await RecentRoomsService.removeRoom('SAME1', profileId: 'profile-a', endpoint: endpointA);
|
||||
expect(RecentRoomsService.getRecentRooms(profileId: 'profile-a', endpoint: endpointA), isEmpty);
|
||||
expect(RecentRoomsService.getRecentRooms(profileId: 'profile-a', endpoint: endpointB).single.code, 'SAME1');
|
||||
});
|
||||
|
||||
test('profile history remains bounded across relay scopes and deduplicates tuples', () async {
|
||||
for (var index = 0; index < 21; index++) {
|
||||
await RecentRoomsService.addOrUpdateRoom(
|
||||
'R${index.toString().padLeft(4, '0')}',
|
||||
profileId: 'profile-a',
|
||||
endpoint: index.isEven ? endpointA : endpointB,
|
||||
);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 2));
|
||||
}
|
||||
|
||||
final raw = settings.read(SettingsService.recentRoomsForProfile('profile-a'));
|
||||
final rows = jsonDecode(raw!) as List<dynamic>;
|
||||
expect(rows, hasLength(20));
|
||||
expect(rows.map((row) => (row as Map<String, dynamic>)['code']), isNot(contains('R0000')));
|
||||
|
||||
await RecentRoomsService.addOrUpdateRoom('R0020', profileId: 'profile-a', endpoint: endpointA, name: 'Updated');
|
||||
final updatedRows = jsonDecode(settings.read(SettingsService.recentRoomsForProfile('profile-a'))!) as List<dynamic>;
|
||||
expect(updatedRows, hasLength(20));
|
||||
expect(
|
||||
updatedRows.where(
|
||||
(row) =>
|
||||
(row as Map<String, dynamic>)['code'] == 'R0020' &&
|
||||
row['relayScope'] == RecentRoomsService.relayScopeFor(endpointA),
|
||||
),
|
||||
hasLength(1),
|
||||
);
|
||||
});
|
||||
|
||||
test('startup drops unattributable legacy history', () async {
|
||||
SettingsService.resetForTesting();
|
||||
resetSharedPreferencesForTest(
|
||||
initialAsync: {
|
||||
'watch_together_recent_rooms': jsonEncode([
|
||||
{'code': 'OLD01', 'lastUsed': 1},
|
||||
]),
|
||||
},
|
||||
);
|
||||
|
||||
final initialized = await SettingsService.getInstance();
|
||||
expect(initialized.prefs.containsKey('watch_together_recent_rooms'), isFalse);
|
||||
expect(RecentRoomsService.getRecentRooms(profileId: 'profile-a', endpoint: endpointA), isEmpty);
|
||||
});
|
||||
}
|
||||
@@ -209,6 +209,74 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
test('anyone-mode: invalid controls are rejected and the queue continues', () {
|
||||
fakeAsync((async) {
|
||||
final room = _Room(async, controlMode: ControlMode.anyone);
|
||||
final actions = <(String, PlaybackActionHint)>[];
|
||||
room.host.onRemoteAction = (peer, hint) => actions.add((peer, hint));
|
||||
room.hostStartsMedia();
|
||||
room.guestJoinsMedia();
|
||||
room.bothBecomeReady();
|
||||
final delay = room.lastHostState().anchorHostTimeMs - room.nowMs();
|
||||
async.elapse(Duration(milliseconds: delay + 100));
|
||||
room.hostPlayer.commandLog.clear();
|
||||
final statesBefore = room.hostService.outgoingLog
|
||||
.where((message) => message.type == SyncMessageType.state)
|
||||
.length;
|
||||
final stateBefore = room.lastHostState();
|
||||
|
||||
SyncMessage wireControl(ControlRequest request) {
|
||||
return SyncMessage.fromJson(SyncMessage.control(request, peerId: 'forged-peer').toJson());
|
||||
}
|
||||
|
||||
room.guestService.sendTo(
|
||||
'host',
|
||||
wireControl(
|
||||
ControlRequest(kind: ControlRequestKind.seek, positionMs: room.hostPlayer.state.duration.inMilliseconds + 1),
|
||||
),
|
||||
);
|
||||
room.guestService.sendTo(
|
||||
'host',
|
||||
wireControl(const ControlRequest(kind: ControlRequestKind.rate, rate: 4.000001)),
|
||||
);
|
||||
async.flushMicrotasks();
|
||||
|
||||
expect(room.hostPlayer.commandLog, isEmpty);
|
||||
expect(actions, isEmpty);
|
||||
expect(
|
||||
room.hostService.outgoingLog.where((message) => message.type == SyncMessageType.state),
|
||||
hasLength(statesBefore),
|
||||
);
|
||||
expect(room.lastHostState().seq, stateBefore.seq);
|
||||
expect(room.lastHostState().anchorPositionMs, stateBefore.anchorPositionMs);
|
||||
expect(room.lastHostState().rate, stateBefore.rate);
|
||||
|
||||
room.guestService.sendTo(
|
||||
'host',
|
||||
wireControl(const ControlRequest(kind: ControlRequestKind.seek, positionMs: 600000)),
|
||||
);
|
||||
async.flushMicrotasks();
|
||||
room.guestService.sendTo('host', wireControl(const ControlRequest(kind: ControlRequestKind.rate, rate: 0.25)));
|
||||
async.flushMicrotasks();
|
||||
|
||||
expect(room.hostPlayer.commandLog, ['seek:600000', 'rate:0.25']);
|
||||
expect(actions, [('guest', PlaybackActionHint.seek), ('guest', PlaybackActionHint.rate)]);
|
||||
final acceptedStates = room.hostService.outgoingLog
|
||||
.where((message) => message.type == SyncMessageType.state)
|
||||
.skip(statesBefore)
|
||||
.map((message) => message.state!)
|
||||
.toList();
|
||||
expect(acceptedStates, hasLength(2));
|
||||
expect(acceptedStates[0].anchorPositionMs, 600000);
|
||||
expect(acceptedStates[0].actionHint, PlaybackActionHint.seek);
|
||||
expect(acceptedStates[0].actorPeerId, 'guest');
|
||||
expect(acceptedStates[1].rate, 0.25);
|
||||
expect(acceptedStates[1].actionHint, PlaybackActionHint.rate);
|
||||
expect(acceptedStates[1].actorPeerId, 'guest');
|
||||
room.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
test('guest controller starts clock-sync pings automatically', () {
|
||||
fakeAsync((async) {
|
||||
final room = _Room(async);
|
||||
@@ -221,15 +289,14 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
test('v1 peers are flagged and never gate the start', () {
|
||||
test('v2 peers are flagged and never gate the start', () {
|
||||
fakeAsync((async) {
|
||||
final needsUpdate = <String>[];
|
||||
final room = _Room(async);
|
||||
room.host.onPeerNeedsUpdate = needsUpdate.add;
|
||||
|
||||
// A legacy client joins on its own connection: its join message has no
|
||||
// version field (the relay stamps the sender id, so it must really
|
||||
// connect as itself — peerId spoofing is rewritten).
|
||||
// A sync-protocol-2 client joins on its own connection. The relay
|
||||
// stamps the sender ID, so it must really connect as itself.
|
||||
final legacyService = room.hub.register('legacy');
|
||||
legacyService.sendTo(
|
||||
'host',
|
||||
@@ -239,6 +306,7 @@ void main() {
|
||||
peerId: 'legacy',
|
||||
displayName: 'Old App',
|
||||
isHost: false,
|
||||
version: 2,
|
||||
),
|
||||
);
|
||||
async.flushMicrotasks();
|
||||
@@ -253,6 +321,34 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
test('versionless legacy peers are flagged and never gate the start', () {
|
||||
fakeAsync((async) {
|
||||
final needsUpdate = <String>[];
|
||||
final room = _Room(async);
|
||||
room.host.onPeerNeedsUpdate = needsUpdate.add;
|
||||
|
||||
final versionlessService = room.hub.register('versionless');
|
||||
versionlessService.sendTo(
|
||||
'host',
|
||||
SyncMessage(
|
||||
type: SyncMessageType.join,
|
||||
timestamp: room.nowMs(),
|
||||
peerId: 'versionless',
|
||||
displayName: 'Old App',
|
||||
isHost: false,
|
||||
),
|
||||
);
|
||||
async.flushMicrotasks();
|
||||
expect(needsUpdate, ['versionless']);
|
||||
|
||||
room.hostStartsMedia();
|
||||
room.guestJoinsMedia();
|
||||
room.bothBecomeReady();
|
||||
expect(room.lastHostState().phase, PlaybackPhase.playing);
|
||||
room.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
test('guest reconnect re-requests state and the host answers directly', () {
|
||||
fakeAsync((async) {
|
||||
final room = _Room(async);
|
||||
@@ -274,6 +370,46 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
test('only relay-stamped state from the declared host reaches guest reconciliation', () {
|
||||
fakeAsync((async) {
|
||||
final room = _Room(async);
|
||||
final mediaDispatches = <String>[];
|
||||
room.guest.onMediaStateReceived = (ratingKey, serverId, title) => mediaDispatches.add(ratingKey);
|
||||
const state = PlaybackState(
|
||||
seq: 10,
|
||||
ratingKey: 'relay-authority',
|
||||
serverId: 'srv',
|
||||
mediaTitle: 'Authorized',
|
||||
phase: PlaybackPhase.loading,
|
||||
anchorPositionMs: 0,
|
||||
anchorHostTimeMs: _epochMs,
|
||||
rate: 1,
|
||||
controlMode: ControlMode.hostOnly,
|
||||
);
|
||||
final unprivileged = room.hub.register('unprivileged');
|
||||
|
||||
// The fake relay overwrites the payload claim with the connection's
|
||||
// routing ID, just like the production relay envelope parser.
|
||||
unprivileged.broadcast(SyncMessage.state(state, peerId: 'host'));
|
||||
async.flushMicrotasks();
|
||||
expect(mediaDispatches, isEmpty);
|
||||
|
||||
room.hostService.broadcast(SyncMessage.state(state, peerId: 'host'));
|
||||
async.flushMicrotasks();
|
||||
expect(mediaDispatches, ['relay-authority']);
|
||||
room.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
test('fake relay rejects duplicate routing IDs instead of replacing authority', () async {
|
||||
final hub = FakeRelayHub();
|
||||
hub.register('reserved');
|
||||
|
||||
expect(() => hub.register('reserved'), throwsStateError);
|
||||
|
||||
await hub.dispose();
|
||||
});
|
||||
|
||||
group('hostExitedPlayer routing', () {
|
||||
test('rides the ordered queue: never overtakes states sent before it', () {
|
||||
fakeAsync((async) {
|
||||
|
||||
@@ -55,6 +55,20 @@ void main() {
|
||||
expect(harness.onLeaveSessionCalls, 0);
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets('best-effort leave failure is handled by the overlay', (tester) async {
|
||||
final harness = _OverlayHarness(isHost: false, leaveError: StateError('release failed'));
|
||||
addTearDown(harness.dispose);
|
||||
await tester.pumpWidget(harness.build());
|
||||
|
||||
await _openLeaveConfirmation(tester, harness);
|
||||
await tester.tap(find.text(t.watchTogether.leave));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(harness.provider.leaveCalls, 1);
|
||||
expect(harness.onLeaveSessionCalls, 1);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _openLeaveConfirmation(WidgetTester tester, _OverlayHarness harness) async {
|
||||
@@ -69,7 +83,8 @@ Future<void> _openLeaveConfirmation(WidgetTester tester, _OverlayHarness harness
|
||||
}
|
||||
|
||||
class _OverlayHarness {
|
||||
_OverlayHarness({required bool isHost}) : provider = _FakeWatchTogetherProvider(isHostValue: isHost);
|
||||
_OverlayHarness({required bool isHost, Object? leaveError})
|
||||
: provider = _FakeWatchTogetherProvider(isHostValue: isHost, leaveError: leaveError);
|
||||
|
||||
static const indicatorKey = Key('watch-together-session-indicator');
|
||||
|
||||
@@ -101,9 +116,10 @@ class _OverlayHarness {
|
||||
}
|
||||
|
||||
class _FakeWatchTogetherProvider extends WatchTogetherProvider {
|
||||
_FakeWatchTogetherProvider({required this.isHostValue});
|
||||
_FakeWatchTogetherProvider({required this.isHostValue, this.leaveError});
|
||||
|
||||
final bool isHostValue;
|
||||
final Object? leaveError;
|
||||
var leaveCalls = 0;
|
||||
var _isDisposing = false;
|
||||
|
||||
@@ -127,6 +143,8 @@ class _FakeWatchTogetherProvider extends WatchTogetherProvider {
|
||||
@override
|
||||
Future<void> leaveSession() async {
|
||||
if (!_isDisposing) leaveCalls++;
|
||||
final error = leaveError;
|
||||
if (error != null) throw error;
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,256 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:fake_async/fake_async.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/media/ids.dart';
|
||||
import 'package:plezy/watch_together/services/watch_together_relay_endpoint.dart';
|
||||
import 'package:plezy/watch_together/models/sync_message.dart';
|
||||
import 'package:plezy/watch_together/models/watch_session.dart';
|
||||
import 'package:plezy/watch_together/providers/watch_together_provider.dart';
|
||||
import 'package:plezy/watch_together/services/watch_together_peer_service.dart';
|
||||
import 'package:plezy/watch_together/services/relay_protocol.g.dart';
|
||||
|
||||
import '../test_helpers/prefs.dart';
|
||||
|
||||
class _FakeWatchTogetherPeerService extends WatchTogetherPeerService {
|
||||
_FakeWatchTogetherPeerService(
|
||||
this.sequence, {
|
||||
required bool hostInitiallyConnected,
|
||||
required this.rejectDisconnectedTargets,
|
||||
this.releaseError,
|
||||
this.releaseBarrier,
|
||||
}) : _hostConnected = hostInitiallyConnected;
|
||||
|
||||
final int sequence;
|
||||
final bool rejectDisconnectedTargets;
|
||||
final Object? releaseError;
|
||||
final Future<void>? releaseBarrier;
|
||||
final _peerConnectedController = StreamController<String>.broadcast();
|
||||
final _peerDisconnectedController = StreamController<String>.broadcast();
|
||||
final _messageController = StreamController<SyncMessage>.broadcast();
|
||||
final _errorController = StreamController<PeerError>.broadcast();
|
||||
final _sessionEndedController = StreamController<void>.broadcast();
|
||||
final List<SyncMessage> broadcasts = [];
|
||||
final List<(String, SyncMessage)> directMessages = [];
|
||||
|
||||
String? _sessionId;
|
||||
String? _myPeerId;
|
||||
String? _hostPeerId;
|
||||
bool _isHost = false;
|
||||
bool _disposed = false;
|
||||
bool _didDisconnect = false;
|
||||
int releaseCalls = 0;
|
||||
bool _hostConnected;
|
||||
|
||||
@override
|
||||
Stream<String> get onPeerConnected => _peerConnectedController.stream;
|
||||
|
||||
@override
|
||||
Stream<String> get onPeerDisconnected => _peerDisconnectedController.stream;
|
||||
|
||||
@override
|
||||
Stream<SyncMessage> get onMessageReceived => _messageController.stream;
|
||||
|
||||
@override
|
||||
Stream<PeerError> get onError => _errorController.stream;
|
||||
|
||||
@override
|
||||
Stream<void> get onSessionEnded => _sessionEndedController.stream;
|
||||
|
||||
@override
|
||||
String? get sessionId => _sessionId;
|
||||
|
||||
@override
|
||||
String? get myPeerId => _myPeerId;
|
||||
|
||||
@override
|
||||
String? get hostPeerId => _hostPeerId;
|
||||
|
||||
@override
|
||||
bool get isHost => _isHost;
|
||||
|
||||
@override
|
||||
List<String> get connectedPeers => !_isHost && _sessionId != null && _hostConnected ? ['wt-$_sessionId'] : const [];
|
||||
|
||||
@override
|
||||
Future<String> createSession({String? sessionId}) {
|
||||
_sessionId = (sessionId ?? 'ROOM$sequence').toUpperCase();
|
||||
_myPeerId = 'wt-$_sessionId';
|
||||
_hostPeerId = _myPeerId;
|
||||
_isHost = true;
|
||||
return Future.value(_sessionId);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> joinSession(String sessionId) {
|
||||
_sessionId = sessionId.toUpperCase();
|
||||
_myPeerId = 'guest-$sequence';
|
||||
_hostPeerId = 'wt-$_sessionId';
|
||||
_isHost = false;
|
||||
return Future.value();
|
||||
}
|
||||
|
||||
@override
|
||||
void broadcast(SyncMessage message) {
|
||||
broadcasts.add(message);
|
||||
}
|
||||
|
||||
@override
|
||||
void sendTo(String peerId, SyncMessage message) {
|
||||
if (rejectDisconnectedTargets && !connectedPeers.contains(peerId)) {
|
||||
_errorController.add(
|
||||
const PeerError(type: PeerErrorType.serverError, message: 'Peer is not in the room', serverCode: 'not_in_room'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
directMessages.add((peerId, message));
|
||||
}
|
||||
|
||||
void emitError(PeerError error) => _errorController.add(error);
|
||||
void emitPeerConnected(String peerId) {
|
||||
if (peerId == _hostPeerId) _hostConnected = true;
|
||||
_peerConnectedController.add(peerId);
|
||||
}
|
||||
|
||||
void emitPeerDisconnected(String peerId) {
|
||||
if (peerId == _hostPeerId) _hostConnected = false;
|
||||
_peerDisconnectedController.add(peerId);
|
||||
}
|
||||
|
||||
void emitSessionEnded() => _sessionEndedController.add(null);
|
||||
void emitMessage(SyncMessage message) => _messageController.add(message);
|
||||
|
||||
bool get hasRelayListeners =>
|
||||
_peerConnectedController.hasListener ||
|
||||
_peerDisconnectedController.hasListener ||
|
||||
_messageController.hasListener ||
|
||||
_errorController.hasListener ||
|
||||
_sessionEndedController.hasListener;
|
||||
|
||||
bool get isDisposed => _disposed;
|
||||
bool get didDisconnect => _didDisconnect;
|
||||
|
||||
@override
|
||||
Future<void> releaseSession() async {
|
||||
releaseCalls++;
|
||||
final barrier = releaseBarrier;
|
||||
if (barrier != null) await barrier;
|
||||
final error = releaseError;
|
||||
if (error != null) throw error;
|
||||
}
|
||||
|
||||
void reconnect() => onReconnected?.call();
|
||||
@override
|
||||
Future<void> disconnect() {
|
||||
_didDisconnect = true;
|
||||
_sessionId = null;
|
||||
_myPeerId = null;
|
||||
_hostPeerId = null;
|
||||
_isHost = false;
|
||||
_hostConnected = false;
|
||||
return Future.value();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_peerConnectedController.close();
|
||||
_peerDisconnectedController.close();
|
||||
_messageController.close();
|
||||
_errorController.close();
|
||||
_sessionEndedController.close();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class _FakePeerServiceFactory {
|
||||
_FakePeerServiceFactory({
|
||||
this.hostInitiallyConnected = true,
|
||||
this.rejectDisconnectedTargets = false,
|
||||
this.releaseError,
|
||||
this.releaseBarrier,
|
||||
});
|
||||
|
||||
final bool hostInitiallyConnected;
|
||||
final bool rejectDisconnectedTargets;
|
||||
final Object? releaseError;
|
||||
final Future<void>? releaseBarrier;
|
||||
final List<_FakeWatchTogetherPeerService> services = [];
|
||||
WatchTogetherPeerService call({WatchTogetherRelayEndpoint? endpoint}) {
|
||||
final service = _FakeWatchTogetherPeerService(
|
||||
services.length + 1,
|
||||
hostInitiallyConnected: hostInitiallyConnected,
|
||||
rejectDisconnectedTargets: rejectDisconnectedTargets,
|
||||
releaseError: releaseError,
|
||||
releaseBarrier: releaseBarrier,
|
||||
);
|
||||
services.add(service);
|
||||
return service;
|
||||
}
|
||||
}
|
||||
|
||||
PeerError _transportError([String message = 'WebSocket error: connection reset']) {
|
||||
return PeerError(type: PeerErrorType.serverError, message: message, originalError: StateError('connection reset'));
|
||||
}
|
||||
|
||||
Future<void> _flushProviderEvents() async {
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
}
|
||||
|
||||
const _providerHostId = 'relay-authoritative-host';
|
||||
|
||||
typedef _ProviderRelayHandler = void Function(WebSocket socket, Map<String, dynamic> message);
|
||||
|
||||
class _ProviderRelay {
|
||||
_ProviderRelay._(this._server);
|
||||
|
||||
final HttpServer _server;
|
||||
final List<WebSocket> _sockets = [];
|
||||
final List<Map<String, dynamic>> messages = [];
|
||||
|
||||
String get baseUrl => 'http://${_server.address.address}:${_server.port}';
|
||||
|
||||
static Future<_ProviderRelay> start(_ProviderRelayHandler handler) async {
|
||||
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
final relay = _ProviderRelay._(server);
|
||||
server.listen((request) async {
|
||||
if (request.uri.path != '/relay') {
|
||||
request.response.statusCode = HttpStatus.notFound;
|
||||
await request.response.close();
|
||||
return;
|
||||
}
|
||||
final socket = await WebSocketTransformer.upgrade(request);
|
||||
relay._sockets.add(socket);
|
||||
socket.listen((data) {
|
||||
final message = jsonDecode(data as String) as Map<String, dynamic>;
|
||||
relay.messages.add(message);
|
||||
handler(socket, message);
|
||||
});
|
||||
});
|
||||
return relay;
|
||||
}
|
||||
|
||||
void send(WebSocket socket, Map<String, dynamic> message) => socket.add(jsonEncode(message));
|
||||
|
||||
Future<void> close() async {
|
||||
for (final socket in _sockets) {
|
||||
await socket.close();
|
||||
}
|
||||
await _server.close(force: true);
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('generated relay versions match the protocol specification', () {
|
||||
final spec = (jsonDecode(File('relay_protocol.json').readAsStringSync()) as Map).cast<String, dynamic>();
|
||||
expect(RelayProtocol.protocolVersion, spec['protocolVersion']);
|
||||
expect(RelayProtocol.legacyProtocolVersion, spec['legacyProtocolVersion']);
|
||||
});
|
||||
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(() {
|
||||
@@ -210,6 +453,389 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('WatchTogetherProvider — reconnect recovery', () {
|
||||
test('restores only the matching host transport error and preserves session fields', () async {
|
||||
final factory = _FakePeerServiceFactory();
|
||||
final provider = WatchTogetherProvider(peerServiceFactory: factory.call);
|
||||
final observedStates = <SessionState?>[];
|
||||
provider.addListener(() => observedStates.add(provider.session?.state));
|
||||
|
||||
await provider.createSession(
|
||||
controlMode: ControlMode.anyone,
|
||||
relayEndpoint: WatchTogetherRelayEndpoint.defaultEndpoint,
|
||||
displayName: 'Host',
|
||||
sessionId: 'room1',
|
||||
mediaRatingKey: 'rating-1',
|
||||
mediaServerId: 'server-1',
|
||||
mediaTitle: 'Episode 1',
|
||||
);
|
||||
await _flushProviderEvents();
|
||||
final established = provider.session!;
|
||||
final service = factory.services.single;
|
||||
observedStates.clear();
|
||||
service.broadcasts.clear();
|
||||
|
||||
service.emitError(_transportError());
|
||||
await _flushProviderEvents();
|
||||
expect(provider.session?.state, SessionState.error);
|
||||
expect(provider.isConnected, isFalse);
|
||||
|
||||
service.reconnect();
|
||||
await _flushProviderEvents();
|
||||
|
||||
expect(observedStates, [SessionState.error, SessionState.connected]);
|
||||
expect(provider.isConnected, isTrue);
|
||||
expect(provider.session?.errorMessage, isNull);
|
||||
expect(provider.session?.sessionId, established.sessionId);
|
||||
expect(provider.session?.role, established.role);
|
||||
expect(provider.session?.controlMode, established.controlMode);
|
||||
expect(provider.session?.mediaRatingKey, established.mediaRatingKey);
|
||||
expect(provider.session?.mediaServerId, established.mediaServerId);
|
||||
expect(provider.session?.mediaTitle, established.mediaTitle);
|
||||
expect(service.broadcasts.where((message) => message.type == SyncMessageType.join), hasLength(1));
|
||||
|
||||
await provider.leaveSession();
|
||||
provider.dispose();
|
||||
});
|
||||
|
||||
test('guest waits for a reconnecting declared host before requesting state', () async {
|
||||
final factory = _FakePeerServiceFactory(hostInitiallyConnected: false, rejectDisconnectedTargets: true);
|
||||
final provider = WatchTogetherProvider(peerServiceFactory: factory.call);
|
||||
|
||||
await provider.joinSession(
|
||||
'late1',
|
||||
relayEndpoint: WatchTogetherRelayEndpoint.defaultEndpoint,
|
||||
displayName: 'Guest',
|
||||
);
|
||||
await _flushProviderEvents();
|
||||
final service = factory.services.single;
|
||||
final hostPeerId = provider.session!.hostPeerId!;
|
||||
|
||||
expect(provider.session?.state, SessionState.connected);
|
||||
expect(service.connectedPeers, isEmpty);
|
||||
expect(service.directMessages.where((entry) => entry.$2.type == SyncMessageType.requestState), isEmpty);
|
||||
|
||||
service.emitPeerConnected(hostPeerId);
|
||||
await _flushProviderEvents();
|
||||
|
||||
expect(service.connectedPeers, [hostPeerId]);
|
||||
expect(
|
||||
service.directMessages.where(
|
||||
(entry) => entry.$1 == hostPeerId && entry.$2.type == SyncMessageType.requestState,
|
||||
),
|
||||
hasLength(1),
|
||||
);
|
||||
expect(provider.session?.state, SessionState.connected);
|
||||
|
||||
await provider.leaveSession();
|
||||
provider.dispose();
|
||||
});
|
||||
|
||||
test('guest recovery re-announces and re-requests authoritative state once', () async {
|
||||
final factory = _FakePeerServiceFactory();
|
||||
final provider = WatchTogetherProvider(peerServiceFactory: factory.call);
|
||||
await provider.joinSession(
|
||||
'room2',
|
||||
relayEndpoint: WatchTogetherRelayEndpoint.defaultEndpoint,
|
||||
displayName: 'Guest',
|
||||
);
|
||||
await _flushProviderEvents();
|
||||
final service = factory.services.single;
|
||||
service.broadcasts.clear();
|
||||
service.directMessages.clear();
|
||||
|
||||
service.emitError(_transportError());
|
||||
await _flushProviderEvents();
|
||||
service.reconnect();
|
||||
await _flushProviderEvents();
|
||||
|
||||
expect(provider.session?.state, SessionState.connected);
|
||||
expect(service.broadcasts.where((message) => message.type == SyncMessageType.join), hasLength(1));
|
||||
expect(service.directMessages.where((entry) => entry.$2.type == SyncMessageType.requestState), hasLength(1));
|
||||
|
||||
await provider.leaveSession();
|
||||
provider.dispose();
|
||||
});
|
||||
|
||||
test('relay errors remain terminal when the current service reconnects', () async {
|
||||
final factory = _FakePeerServiceFactory();
|
||||
final provider = WatchTogetherProvider(peerServiceFactory: factory.call);
|
||||
final observedStates = <SessionState?>[];
|
||||
provider.addListener(() => observedStates.add(provider.session?.state));
|
||||
await provider.createSession(
|
||||
controlMode: ControlMode.hostOnly,
|
||||
relayEndpoint: WatchTogetherRelayEndpoint.defaultEndpoint,
|
||||
sessionId: 'room3',
|
||||
);
|
||||
await _flushProviderEvents();
|
||||
final service = factory.services.single;
|
||||
observedStates.clear();
|
||||
|
||||
service.emitError(
|
||||
const PeerError(type: PeerErrorType.serverError, message: 'Room was rejected', serverCode: 'room_rejected'),
|
||||
);
|
||||
await _flushProviderEvents();
|
||||
service.reconnect();
|
||||
await _flushProviderEvents();
|
||||
|
||||
expect(observedStates, [SessionState.error]);
|
||||
expect(provider.session?.state, SessionState.error);
|
||||
expect(provider.session?.errorMessage, 'Room was rejected');
|
||||
expect(provider.isConnected, isFalse);
|
||||
|
||||
await provider.leaveSession();
|
||||
provider.dispose();
|
||||
});
|
||||
|
||||
test('host-loss expiry supersedes a recoverable guest transport error', () {
|
||||
fakeAsync((async) {
|
||||
final factory = _FakePeerServiceFactory();
|
||||
final provider = WatchTogetherProvider(peerServiceFactory: factory.call);
|
||||
var joined = false;
|
||||
unawaited(
|
||||
provider
|
||||
.joinSession('room4', relayEndpoint: WatchTogetherRelayEndpoint.defaultEndpoint, displayName: 'Guest')
|
||||
.then((_) => joined = true),
|
||||
);
|
||||
async.flushMicrotasks();
|
||||
expect(joined, isTrue);
|
||||
final service = factory.services.single;
|
||||
final hostPeerId = provider.session!.hostPeerId!;
|
||||
|
||||
service.emitError(_transportError());
|
||||
async.flushMicrotasks();
|
||||
expect(provider.session?.state, SessionState.error);
|
||||
|
||||
service.emitPeerDisconnected(hostPeerId);
|
||||
async.flushMicrotasks();
|
||||
expect(provider.isWaitingForHostReconnect, isTrue);
|
||||
async.elapse(const Duration(seconds: 15));
|
||||
async.flushMicrotasks();
|
||||
expect(provider.session?.errorMessage, 'Host left the session');
|
||||
|
||||
service.reconnect();
|
||||
async.flushMicrotasks();
|
||||
expect(provider.session?.state, SessionState.error);
|
||||
expect(provider.session?.errorMessage, 'Host left the session');
|
||||
|
||||
unawaited(provider.leaveSession());
|
||||
async.flushMicrotasks();
|
||||
provider.dispose();
|
||||
async.flushMicrotasks();
|
||||
});
|
||||
});
|
||||
|
||||
test('stale service callback cannot mutate or resync a replacement session', () async {
|
||||
final factory = _FakePeerServiceFactory();
|
||||
final provider = WatchTogetherProvider(peerServiceFactory: factory.call);
|
||||
var notifications = 0;
|
||||
provider.addListener(() => notifications++);
|
||||
|
||||
await provider.createSession(
|
||||
controlMode: ControlMode.hostOnly,
|
||||
relayEndpoint: WatchTogetherRelayEndpoint.defaultEndpoint,
|
||||
sessionId: 'old1',
|
||||
);
|
||||
await _flushProviderEvents();
|
||||
final oldService = factory.services.single;
|
||||
final staleReconnect = oldService.onReconnected!;
|
||||
oldService.emitError(_transportError('old transport error'));
|
||||
await _flushProviderEvents();
|
||||
|
||||
await provider.createSession(
|
||||
controlMode: ControlMode.anyone,
|
||||
relayEndpoint: WatchTogetherRelayEndpoint.defaultEndpoint,
|
||||
sessionId: 'new1',
|
||||
);
|
||||
await _flushProviderEvents();
|
||||
final currentService = factory.services.last;
|
||||
currentService.broadcasts.clear();
|
||||
notifications = 0;
|
||||
final replacement = provider.session;
|
||||
|
||||
staleReconnect();
|
||||
await _flushProviderEvents();
|
||||
expect(provider.session, replacement);
|
||||
expect(notifications, 0);
|
||||
expect(currentService.broadcasts, isEmpty);
|
||||
|
||||
currentService.emitError(_transportError('current transport error'));
|
||||
await _flushProviderEvents();
|
||||
currentService.reconnect();
|
||||
await _flushProviderEvents();
|
||||
expect(provider.session?.state, SessionState.connected);
|
||||
expect(provider.session?.errorMessage, isNull);
|
||||
expect(currentService.broadcasts.where((message) => message.type == SyncMessageType.join), hasLength(1));
|
||||
|
||||
await provider.leaveSession();
|
||||
provider.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
group('WatchTogetherProvider — release cleanup', () {
|
||||
test('release failure is surfaced after local session teardown completes', () async {
|
||||
final releaseFailure = StateError('relay release failed');
|
||||
final factory = _FakePeerServiceFactory(releaseError: releaseFailure);
|
||||
final provider = WatchTogetherProvider(peerServiceFactory: factory.call);
|
||||
addTearDown(provider.dispose);
|
||||
|
||||
await provider.createSession(
|
||||
controlMode: ControlMode.hostOnly,
|
||||
relayEndpoint: WatchTogetherRelayEndpoint.defaultEndpoint,
|
||||
sessionId: 'fail1',
|
||||
);
|
||||
final service = factory.services.single;
|
||||
|
||||
await expectLater(provider.leaveSession(), throwsA(same(releaseFailure)));
|
||||
|
||||
expect(service.releaseCalls, 1);
|
||||
expect(service.didDisconnect, isTrue);
|
||||
expect(service.isDisposed, isTrue);
|
||||
expect(provider.session, isNull);
|
||||
expect(provider.isInSession, isFalse);
|
||||
expect(provider.participants, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('WatchTogetherProvider — relay authority', () {
|
||||
test('an empty successful probe joins the reserved room without creating', () async {
|
||||
late final _ProviderRelay relay;
|
||||
relay = await _ProviderRelay.start((socket, message) {
|
||||
if (message['type'] == 'join') {
|
||||
relay.send(socket, {
|
||||
'type': 'joined',
|
||||
'sessionId': message['sessionId'],
|
||||
'hostPeerId': _providerHostId,
|
||||
'reconnectToken': message['reconnectToken'],
|
||||
'protocolVersion': 2,
|
||||
});
|
||||
} else if (message['type'] == 'leave') {
|
||||
relay.send(socket, {
|
||||
'type': 'left',
|
||||
'sessionId': message['sessionId'],
|
||||
'peerId': message['peerId'],
|
||||
'protocolVersion': 2,
|
||||
});
|
||||
}
|
||||
});
|
||||
addTearDown(relay.close);
|
||||
final endpoint = WatchTogetherRelayEndpoint.resolve(relay.baseUrl);
|
||||
final provider = WatchTogetherProvider();
|
||||
addTearDown(() async {
|
||||
await provider.leaveSession();
|
||||
provider.dispose();
|
||||
});
|
||||
|
||||
final becameHost = await provider.enterRoom('empty1', relayEndpoint: endpoint, displayName: 'Guest');
|
||||
|
||||
expect(becameHost, isFalse);
|
||||
expect(provider.isHost, isFalse);
|
||||
expect(provider.session?.hostPeerId, _providerHostId);
|
||||
final joins = relay.messages.where((message) => message['type'] == 'join').toList();
|
||||
expect(joins, hasLength(2));
|
||||
for (final join in joins) {
|
||||
expect(join['protocolVersion'], 2);
|
||||
expect(join['reconnectToken'], matches(RegExp(r'^[A-Za-z0-9_-]{43}$')));
|
||||
}
|
||||
final leave = relay.messages.singleWhere((message) => message['type'] == 'leave');
|
||||
expect(leave['peerId'], joins.first['peerId']);
|
||||
expect(leave['reconnectToken'], joins.first['reconnectToken']);
|
||||
expect(leave['protocolVersion'], RelayProtocol.protocolVersion);
|
||||
expect(joins.last['peerId'], isNot(joins.first['peerId']));
|
||||
expect(relay.messages.map((message) => message['type']).take(3), ['join', 'leave', 'join']);
|
||||
expect(relay.messages.where((message) => message['type'] == 'create'), isEmpty);
|
||||
});
|
||||
|
||||
test('a room-not-found probe creates with relay-declared host authority', () async {
|
||||
late final _ProviderRelay relay;
|
||||
relay = await _ProviderRelay.start((socket, message) {
|
||||
if (message['type'] == 'join') {
|
||||
relay.send(socket, {'type': 'error', 'code': 'room_not_found', 'message': 'Room not found'});
|
||||
} else if (message['type'] == 'create') {
|
||||
relay.send(socket, {
|
||||
'type': 'created',
|
||||
'sessionId': message['sessionId'],
|
||||
'hostPeerId': message['peerId'],
|
||||
'reconnectToken': message['reconnectToken'],
|
||||
'protocolVersion': 2,
|
||||
});
|
||||
} else if (message['type'] == 'endSession') {
|
||||
relay.send(socket, {'type': 'ended', 'sessionId': message['sessionId'], 'protocolVersion': 2});
|
||||
}
|
||||
});
|
||||
addTearDown(relay.close);
|
||||
final endpoint = WatchTogetherRelayEndpoint.resolve(relay.baseUrl);
|
||||
final provider = WatchTogetherProvider();
|
||||
addTearDown(() async {
|
||||
await provider.leaveSession();
|
||||
provider.dispose();
|
||||
});
|
||||
|
||||
final becameHost = await provider.enterRoom('new01', relayEndpoint: endpoint, displayName: 'Host');
|
||||
|
||||
expect(becameHost, isTrue);
|
||||
expect(provider.isHost, isTrue);
|
||||
final create = relay.messages.singleWhere((message) => message['type'] == 'create');
|
||||
expect(provider.session?.hostPeerId, create['peerId']);
|
||||
expect(create['peerId'], isNot('wt-NEW01'));
|
||||
expect(create['protocolVersion'], 2);
|
||||
expect(create['reconnectToken'], matches(RegExp(r'^[A-Za-z0-9_-]{43}$')));
|
||||
});
|
||||
});
|
||||
|
||||
group('WatchTogetherProvider — terminal room lifecycle', () {
|
||||
test('relay ended notification exits immediately without release or reconnect grace', () async {
|
||||
final factory = _FakePeerServiceFactory();
|
||||
final provider = WatchTogetherProvider(peerServiceFactory: factory.call);
|
||||
var hostExitCalls = 0;
|
||||
provider.onHostExitedPlayer = () => hostExitCalls++;
|
||||
|
||||
await provider.joinSession(
|
||||
'ended1',
|
||||
relayEndpoint: WatchTogetherRelayEndpoint.defaultEndpoint,
|
||||
displayName: 'Guest',
|
||||
);
|
||||
final service = factory.services.single;
|
||||
service.emitPeerDisconnected(provider.session!.hostPeerId!);
|
||||
await _flushProviderEvents();
|
||||
expect(provider.isWaitingForHostReconnect, isTrue);
|
||||
|
||||
service.emitSessionEnded();
|
||||
await _flushProviderEvents();
|
||||
|
||||
expect(hostExitCalls, 1);
|
||||
expect(provider.session, isNull);
|
||||
expect(provider.isWaitingForHostReconnect, isFalse);
|
||||
expect(service.releaseCalls, 0);
|
||||
expect(service.didDisconnect, isTrue);
|
||||
expect(service.isDisposed, isTrue);
|
||||
provider.dispose();
|
||||
});
|
||||
|
||||
test('best-effort host-leave cleanup observes release failures', () async {
|
||||
final releaseFailure = StateError('relay release failed');
|
||||
final factory = _FakePeerServiceFactory(releaseError: releaseFailure);
|
||||
final uncaught = <Object>[];
|
||||
|
||||
await runZonedGuarded(() async {
|
||||
final provider = WatchTogetherProvider(peerServiceFactory: factory.call);
|
||||
await provider.joinSession(
|
||||
'leave2',
|
||||
relayEndpoint: WatchTogetherRelayEndpoint.defaultEndpoint,
|
||||
displayName: 'Guest',
|
||||
);
|
||||
final service = factory.services.single;
|
||||
service.emitMessage(SyncMessage.leave(peerId: provider.session!.hostPeerId!));
|
||||
await _flushProviderEvents();
|
||||
expect(provider.session, isNull);
|
||||
expect(service.releaseCalls, 1);
|
||||
provider.dispose();
|
||||
}, (error, _) => uncaught.add(error));
|
||||
|
||||
expect(uncaught, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('WatchTogetherProvider — dispose hygiene', () {
|
||||
test('participantEvents stream is closed after dispose', () async {
|
||||
final p = WatchTogetherProvider();
|
||||
@@ -223,5 +849,37 @@ void main() {
|
||||
await sub.cancel();
|
||||
expect(streamDone, isTrue);
|
||||
});
|
||||
|
||||
test('dispose detaches local listeners before relay release completes', () async {
|
||||
final releaseCompleter = Completer<void>();
|
||||
final factory = _FakePeerServiceFactory(releaseBarrier: releaseCompleter.future);
|
||||
final provider = WatchTogetherProvider(peerServiceFactory: factory.call);
|
||||
await provider.joinSession(
|
||||
'dispose1',
|
||||
relayEndpoint: WatchTogetherRelayEndpoint.defaultEndpoint,
|
||||
displayName: 'Guest',
|
||||
);
|
||||
final service = factory.services.single;
|
||||
expect(service.hasRelayListeners, isTrue);
|
||||
|
||||
provider.dispose();
|
||||
|
||||
expect(provider.session, isNull);
|
||||
expect(provider.participants, isEmpty);
|
||||
expect(provider.isWaitingForHostReconnect, isFalse);
|
||||
expect(service.hasRelayListeners, isFalse);
|
||||
expect(service.releaseCalls, 1);
|
||||
expect(service.didDisconnect, isFalse);
|
||||
|
||||
service.emitPeerConnected('late-peer');
|
||||
service.emitMessage(SyncMessage.join(peerId: 'late-peer', displayName: 'Late', isHost: false));
|
||||
await _flushProviderEvents();
|
||||
expect(provider.participants, isEmpty);
|
||||
|
||||
releaseCompleter.complete();
|
||||
await _flushProviderEvents();
|
||||
expect(service.didDisconnect, isTrue);
|
||||
expect(service.isDisposed, isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user