onOpen($a); $this->assertSame(['type' => 'welcome', 'peer' => 'a', 'record' => false], $a->lastJson()); } public function test_public_join_returns_others_notifies_them_and_has_no_member_list(): void { $handler = new RelaySignalingHandler; $a = new FakePeer('a'); $b = new FakePeer('b'); $handler->onOpen($a); $handler->onOpen($b); $handler->onMessage($a, $this->json(['type' => 'join', 'room' => 'lobby'])); $handler->onMessage($b, $this->json(['type' => 'join', 'room' => 'lobby'])); $joined = $b->lastJson(); $this->assertSame('joined', $joined['type']); $this->assertSame('public', $joined['roomType']); $this->assertSame(['a'], $joined['peers']); $this->assertArrayNotHasKey('members', $joined); $this->assertContains(['type' => 'peer-joined', 'room' => 'lobby', 'peer' => 'b'], $a->jsonMessages()); } public function test_presence_room_shares_the_member_list_with_info(): void { $handler = new RelaySignalingHandler; $a = new FakePeer('a'); $b = new FakePeer('b'); $handler->onOpen($a); $handler->onOpen($b); $handler->onMessage($a, $this->json(['type' => 'join', 'room' => 'open-presence-lobby', 'info' => ['name' => 'Ann']])); $handler->onMessage($b, $this->json(['type' => 'join', 'room' => 'open-presence-lobby', 'info' => ['name' => 'Bob']])); $joined = $b->lastJson(); $this->assertSame('open-presence', $joined['roomType']); $this->assertSame(['a' => ['name' => 'Ann'], 'b' => ['name' => 'Bob']], $joined['members']); $this->assertContains( ['type' => 'peer-joined', 'room' => 'open-presence-lobby', 'peer' => 'b', 'info' => ['name' => 'Bob']], $a->jsonMessages(), ); } public function test_private_room_is_denied_by_default(): void { $handler = new RelaySignalingHandler; // DefaultRoomAuthorizer denies $a = new FakePeer('a'); $handler->onOpen($a); $handler->onMessage($a, $this->json(['type' => 'join', 'room' => 'private-x'])); $this->assertSame(['type' => 'error', 'error' => 'unauthorized'], $a->lastJson()); $this->assertNull($handler->rooms()->roomOf('a')); } public function test_private_room_is_allowed_by_a_bound_authorizer(): void { $handler = new RelaySignalingHandler(authorizer: new AllowAuthorizer(['role' => 'host'])); $a = new FakePeer('a'); $handler->onOpen($a); $handler->onMessage($a, $this->json(['type' => 'join', 'room' => 'private-x', 'auth' => ['token' => 't']])); $joined = $a->lastJson(); $this->assertSame('joined', $joined['type']); $this->assertSame('private', $joined['roomType']); $this->assertSame('private-x', $handler->rooms()->roomOf('a')); } public function test_relay_forwards_to_the_target_with_the_sender(): void { $handler = new RelaySignalingHandler; $a = new FakePeer('a'); $b = new FakePeer('b'); $handler->onOpen($a); $handler->onOpen($b); $handler->onMessage($a, $this->json(['type' => 'relay', 'to' => 'b', 'data' => ['sdp' => 'OFFER']])); $this->assertSame(['type' => 'relay', 'from' => 'a', 'data' => ['sdp' => 'OFFER']], $b->lastJson()); } public function test_leave_notifies_the_remaining_peers(): void { $handler = new RelaySignalingHandler; $a = new FakePeer('a'); $b = new FakePeer('b'); $handler->onOpen($a); $handler->onOpen($b); $handler->onMessage($a, $this->json(['type' => 'join', 'room' => 'lobby'])); $handler->onMessage($b, $this->json(['type' => 'join', 'room' => 'lobby'])); $handler->onMessage($b, $this->json(['type' => 'leave'])); $this->assertContains(['type' => 'peer-left', 'peer' => 'b'], $a->jsonMessages()); $this->assertSame(['type' => 'left'], $b->lastJson()); } public function test_broadcast_reaches_the_rest_of_the_room_only(): void { $handler = new RelaySignalingHandler; $peers = [new FakePeer('a'), new FakePeer('b'), new FakePeer('c')]; foreach ($peers as $p) { $handler->onOpen($p); $handler->onMessage($p, $this->json(['type' => 'join', 'room' => 'r'])); } [$a, $b, $c] = $peers; $handler->onMessage($a, $this->json(['type' => 'broadcast', 'data' => ['x' => 1]])); $expected = ['type' => 'broadcast', 'from' => 'a', 'data' => ['x' => 1]]; $this->assertContains($expected, $b->jsonMessages()); $this->assertContains($expected, $c->jsonMessages()); $this->assertNotContains($expected, $a->jsonMessages()); } public function test_streamed_binary_audio_is_recorded_and_finalized_on_disconnect(): void { $store = new MemoryRecordingStore; $events = new CollectingEvents; $handler = new RelaySignalingHandler(recorder: $store, events: $events); $a = new FakePeer('a'); $handler->onOpen($a); $this->assertTrue($a->lastJson()['record'], 'welcome should tell the client to record'); $handler->onMessage($a, $this->json(['type' => 'join', 'room' => 'lobby'])); $handler->onBinary($a, 'AUDIO-1'); $handler->onBinary($a, 'AUDIO-2'); $this->assertSame('AUDIO-1AUDIO-2', $store->data['lobby|a']); $handler->onClose($a); $this->assertContains('lobby|a', $store->finalized); $finalized = $events->ofType('recording-finalized'); $this->assertCount(1, $finalized); $this->assertSame('mem://lobby|a', $finalized[0]['recording']); } public function test_binary_without_a_recorder_is_ignored(): void { $handler = new RelaySignalingHandler; // no recorder $a = new FakePeer('a'); $handler->onOpen($a); $handler->onMessage($a, $this->json(['type' => 'join', 'room' => 'lobby'])); $handler->onBinary($a, 'AUDIO'); // must be a harmless no-op $this->assertTrue(true); } public function test_emits_ordered_call_lifecycle_events(): void { $events = new CollectingEvents; $handler = new RelaySignalingHandler(events: $events); $a = new FakePeer('a'); $handler->onOpen($a); $handler->onMessage($a, $this->json(['type' => 'join', 'room' => 'lobby'])); $handler->onClose($a); $types = $events->types(); $this->assertContains('peer-connected', $types); $this->assertContains('room-joined', $types); $this->assertContains('room-left', $types); $this->assertContains('peer-disconnected', $types); $seqs = array_column($events->events, 'seq'); $this->assertSame(range(1, count($seqs)), $seqs, 'seq should be monotonic from 1'); } public function test_invalid_json_and_unknown_type_are_errors(): void { $handler = new RelaySignalingHandler; $a = new FakePeer('a'); $handler->onOpen($a); $handler->onMessage($a, 'not json'); $this->assertSame('error', $a->lastJson()['type']); $handler->onMessage($a, $this->json(['type' => 'wat'])); $this->assertSame('error', $a->lastJson()['type']); } /** @param array $data */ private function json(array $data): string { return (string) json_encode($data); } }