import 'package:fake_async/fake_async.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/watch_together/models/playback_state.dart'; import 'package:plezy/watch_together/models/sync_message.dart'; import 'package:plezy/watch_together/models/watch_session.dart'; import 'package:plezy/watch_together/services/watch_together_controller.dart'; import '../test_helpers/watch_together_fakes.dart'; const _epochMs = 1000000; /// Two live controllers (host + guest) bridged by an in-memory relay. class _Room { _Room(this.async, {ControlMode controlMode = ControlMode.hostOnly}) { hostService = hub.register('host'); guestService = hub.register('guest'); host = WatchTogetherController( peerService: hostService, session: WatchSession( sessionId: 'ROOM1', role: SessionRole.host, controlMode: controlMode, state: SessionState.connected, hostPeerId: 'host', ), nowMs: nowMs, ); guest = WatchTogetherController( peerService: guestService, session: WatchSession( sessionId: 'ROOM1', role: SessionRole.guest, controlMode: controlMode, state: SessionState.connected, hostPeerId: 'host', ), nowMs: nowMs, ); hostPlayer = FakeSyncPlayer(position: const Duration(minutes: 2)); guestPlayer = FakeSyncPlayer(position: Duration.zero); guest.announceJoin('Guest'); host.announceJoin('Host'); async.flushMicrotasks(); } final FakeAsync async; final hub = FakeRelayHub(); late final HubPeerService hostService; late final HubPeerService guestService; late final WatchTogetherController host; late final WatchTogetherController guest; late final FakeSyncPlayer hostPlayer; late final FakeSyncPlayer guestPlayer; int nowMs() => _epochMs + async.elapsed.inMilliseconds; PlaybackState lastHostState() => hostService.outgoingLog.lastWhere((m) => m.type == SyncMessageType.state).state!; void hostStartsMedia({String ratingKey = 'rk1', bool hasFirstFrame = false}) { host.attachPlayer( hostPlayer, ratingKey: ratingKey, serverId: 'srv', mediaTitle: 'Ep', hasFirstFrame: hasFirstFrame, ); host.setCurrentMedia(ratingKey: ratingKey, serverId: 'srv', mediaTitle: 'Ep'); async.flushMicrotasks(); } void guestJoinsMedia({String ratingKey = 'rk1'}) { guest.attachPlayer(guestPlayer, ratingKey: ratingKey, serverId: 'srv'); async.flushMicrotasks(); } void bothBecomeReady() { hostPlayer.emitPlaybackRestart(); guestPlayer.emitPlaybackRestart(); async.flushMicrotasks(); } void dispose() { host.dispose(); guest.dispose(); hub.dispose(); } } void main() { test('full flow: join, media dispatch, load, one simultaneous start — no loops', () { fakeAsync((async) { final mediaDispatches = []; final room = _Room(async); room.guest.onMediaStateReceived = (rk, sid, title) => mediaDispatches.add(rk); // Host opens media; guest hears about it from the loading state even // though the host hasn't finished loading (joiners load in parallel). room.hostStartsMedia(); expect(mediaDispatches, ['rk1']); // Guest loads FIRST (the original bug scenario). room.guestJoinsMedia(); room.guestPlayer.emitPlaybackRestart(); async.flushMicrotasks(); async.elapse(const Duration(seconds: 4)); // While the host loads, the guest must never have been told to play. expect(room.guestPlayer.state.playing, isFalse); expect(room.guestPlayer.commandLog.where((c) => c == 'play'), isEmpty); // Host finishes loading → scheduled start lands on both simultaneously. room.hostPlayer.emitPlaybackRestart(); async.flushMicrotasks(); final state = room.lastHostState(); expect(state.phase, PlaybackPhase.playing); final delay = state.anchorHostTimeMs - room.nowMs(); expect(delay, greaterThan(0)); async.elapse(Duration(milliseconds: delay - 50)); expect(room.hostPlayer.state.playing, isFalse); expect(room.guestPlayer.state.playing, isFalse); async.elapse(const Duration(milliseconds: 100)); expect(room.hostPlayer.state.playing, isTrue); expect(room.guestPlayer.state.playing, isTrue); // And the guest was aligned to the host's anchor position. expect((room.guestPlayer.state.position.inMilliseconds - state.anchorPositionMs).abs(), lessThanOrEqualTo(500)); room.dispose(); }); }); test('episode switch: state arriving during the guest detach gap is not lost', () { fakeAsync((async) { final mediaDispatches = []; final room = _Room(async); room.guest.onMediaStateReceived = (rk, sid, title) => mediaDispatches.add(rk); room.hostStartsMedia(); room.guestJoinsMedia(); room.bothBecomeReady(); final delay = room.lastHostState().anchorHostTimeMs - room.nowMs(); async.elapse(Duration(milliseconds: delay + 100)); expect(room.guestPlayer.state.playing, isTrue); // Guest detaches (reload gap) — and ONLY THEN the host switches media. room.guest.detachPlayer(); async.flushMicrotasks(); room.host.setCurrentMedia(ratingKey: 'rk2', serverId: 'srv', mediaTitle: 'Ep 2'); room.host.detachPlayer(); room.host.attachPlayer(room.hostPlayer, ratingKey: 'rk2', serverId: 'srv', mediaTitle: 'Ep 2'); async.flushMicrotasks(); // The guest controller was detached but session-scoped routing caught // the new epoch. expect(mediaDispatches, contains('rk2')); // Guest re-attaches for the new episode; both load; room starts again. room.guest.attachPlayer(room.guestPlayer, ratingKey: 'rk2', serverId: 'srv'); async.flushMicrotasks(); room.bothBecomeReady(); final resume = room.lastHostState(); expect(resume.phase, PlaybackPhase.playing); expect(resume.ratingKey, 'rk2'); room.dispose(); }); }); test('hostOnly: forged control requests are dropped at the controller', () { fakeAsync((async) { final room = _Room(async); room.hostStartsMedia(); room.guestJoinsMedia(); room.bothBecomeReady(); final delay = room.lastHostState().anchorHostTimeMs - room.nowMs(); async.elapse(Duration(milliseconds: delay + 100)); expect(room.hostPlayer.state.playing, isTrue); room.guestService.sendTo( 'host', SyncMessage.control(const ControlRequest(kind: ControlRequestKind.pause), peerId: 'guest'), ); async.elapse(const Duration(seconds: 1)); expect(room.hostPlayer.state.playing, isTrue); // Ignored. room.dispose(); }); }); test('anyone-mode: guest control requests round-trip through the host', () { fakeAsync((async) { final room = _Room(async, controlMode: ControlMode.anyone); room.hostStartsMedia(); room.guestJoinsMedia(); room.bothBecomeReady(); final delay = room.lastHostState().anchorHostTimeMs - room.nowMs(); async.elapse(Duration(milliseconds: delay + 100)); // Guest presses pause → request → host applies → state pauses guest too. room.guestPlayer.emitPlaying(false); async.flushMicrotasks(); expect(room.hostPlayer.state.playing, isFalse); final paused = room.lastHostState(); expect(paused.phase, PlaybackPhase.paused); expect(paused.actorPeerId, 'guest'); room.dispose(); }); }); 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); // The guest's clock-sync burst starts immediately and sends pings // through its relay-backed peer service. async.elapse(const Duration(seconds: 2)); final pings = room.guestService.outgoingLog.where((m) => m.type == SyncMessageType.ping); expect(pings, isNotEmpty); room.dispose(); }); }); test('v2 peers are flagged and never gate the start', () { fakeAsync((async) { final needsUpdate = []; final room = _Room(async); room.host.onPeerNeedsUpdate = needsUpdate.add; // 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', SyncMessage( type: SyncMessageType.join, timestamp: room.nowMs(), peerId: 'legacy', displayName: 'Old App', isHost: false, version: 2, ), ); async.flushMicrotasks(); expect(needsUpdate, ['legacy']); room.hostStartsMedia(); room.guestJoinsMedia(); room.bothBecomeReady(); // The legacy peer never reports status, yet the room starts. expect(room.lastHostState().phase, PlaybackPhase.playing); room.dispose(); }); }); test('versionless legacy peers are flagged and never gate the start', () { fakeAsync((async) { final needsUpdate = []; 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); room.hostStartsMedia(); room.guestJoinsMedia(); room.bothBecomeReady(); final statesBefore = room.guestService.outgoingLog.length; room.guest.onReconnected(); async.flushMicrotasks(); // Status + requestState went out; host replied with a targeted state. final outgoing = room.guestService.outgoingLog.skip(statesBefore); expect(outgoing.where((m) => m.type == SyncMessageType.status), isNotEmpty); expect(outgoing.where((m) => m.type == SyncMessageType.requestState), isNotEmpty); final targeted = room.hostService.outgoingLog.where((m) => m.type == SyncMessageType.state); expect(targeted, isNotEmpty); room.dispose(); }); }); test('only relay-stamped state from the declared host reaches guest reconciliation', () { fakeAsync((async) { final room = _Room(async); final mediaDispatches = []; 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) { final room = _Room(async); final log = []; room.guest.onMediaStateReceived = (rk, sid, title) => log.add('state:$rk'); room.guest.onHostExitedPlayer = () => log.add('hostExit'); // Host starts media, then exits the player — wire order matters. room.hostStartsMedia(); room.hostService.broadcast(SyncMessage.hostExitedPlayer(peerId: 'host')); async.flushMicrotasks(); expect(log, isNotEmpty); expect(log.first, 'state:rk1'); expect(log.last, 'hostExit'); room.dispose(); }); }); test('is ignored when forged by a non-host peer', () { fakeAsync((async) { final room = _Room(async); var hostExits = 0; room.guest.onHostExitedPlayer = () => hostExits++; final evil = room.hub.register('evil'); evil.broadcast(SyncMessage.hostExitedPlayer(peerId: 'evil')); async.flushMicrotasks(); expect(hostExits, 0); room.dispose(); }); }); test('the host itself never reacts to a hostExitedPlayer echo', () { fakeAsync((async) { final room = _Room(async); var hostExits = 0; room.host.onHostExitedPlayer = () => hostExits++; // A confused/malicious guest sends the message; the host must not // tear down its own epoch. room.guestService.broadcast(SyncMessage.hostExitedPlayer(peerId: 'guest')); async.flushMicrotasks(); expect(hostExits, 0); room.dispose(); }); }); }); }