diff --git a/src/Client.php b/src/Client.php index 10138b6..6c9491c 100644 --- a/src/Client.php +++ b/src/Client.php @@ -160,9 +160,9 @@ public function connect($uri) // start TCP/IP connection to SOCKS server $connecting = $this->connector->connect($socksUri); - $deferred = new Deferred(function ($_, $reject) use ($connecting) { + $deferred = new Deferred(function ($_, $reject) use ($uri, $connecting) { $reject(new RuntimeException( - 'Connection cancelled while waiting for proxy (ECONNABORTED)', + 'Connection to ' . $uri . ' cancelled while waiting for proxy (ECONNABORTED)', defined('SOCKET_ECONNABORTED') ? SOCKET_ECONNABORTED : 103 )); @@ -177,12 +177,12 @@ public function connect($uri) // resolve plain connection once SOCKS protocol is completed $that = $this; $connecting->then( - function (ConnectionInterface $stream) use ($that, $host, $port, $deferred) { - $that->handleConnectedSocks($stream, $host, $port, $deferred); + function (ConnectionInterface $stream) use ($that, $host, $port, $deferred, $uri) { + $that->handleConnectedSocks($stream, $host, $port, $deferred, $uri); }, - function (Exception $e) use ($deferred) { + function (Exception $e) use ($uri, $deferred) { $deferred->reject($e = new RuntimeException( - 'Connection failed because connection to proxy failed (ECONNREFUSED)', + 'Connection to ' . $uri . ' failed because connection to proxy failed (ECONNREFUSED)', defined('SOCKET_ECONNREFUSED') ? SOCKET_ECONNREFUSED : 111, $e )); @@ -213,26 +213,33 @@ function (Exception $e) use ($deferred) { * @param string $host * @param int $port * @param Deferred $deferred + * @param string $uri * @return void * @internal */ - public function handleConnectedSocks(ConnectionInterface $stream, $host, $port, Deferred $deferred) + public function handleConnectedSocks(ConnectionInterface $stream, $host, $port, Deferred $deferred, $uri) { $reader = new StreamReader(); $stream->on('data', array($reader, 'write')); - $stream->on('error', $onError = function (Exception $e) use ($deferred) { - $deferred->reject(new RuntimeException('Stream error while waiting for response from proxy (EIO)', defined('SOCKET_EIO') ? SOCKET_EIO : 5, $e)); + $stream->on('error', $onError = function (Exception $e) use ($deferred, $uri) { + $deferred->reject(new RuntimeException( + 'Connection to ' . $uri . ' failed because connection to proxy caused a stream error (EIO)', + defined('SOCKET_EIO') ? SOCKET_EIO : 5, $e) + ); }); - $stream->on('close', $onClose = function () use ($deferred) { - $deferred->reject(new RuntimeException('Connection to proxy lost while waiting for response (ECONNRESET)', defined('SOCKET_ECONNRESET') ? SOCKET_ECONNRESET : 104)); + $stream->on('close', $onClose = function () use ($deferred, $uri) { + $deferred->reject(new RuntimeException( + 'Connection to ' . $uri . ' failed because connection to proxy was lost while waiting for response from proxy (ECONNRESET)', + defined('SOCKET_ECONNRESET') ? SOCKET_ECONNRESET : 104) + ); }); if ($this->protocolVersion === 5) { - $promise = $this->handleSocks5($stream, $host, $port, $reader); + $promise = $this->handleSocks5($stream, $host, $port, $reader, $uri); } else { - $promise = $this->handleSocks4($stream, $host, $port, $reader); + $promise = $this->handleSocks4($stream, $host, $port, $reader, $uri); } $promise->then(function () use ($deferred, $stream, $reader, $onError, $onClose) { @@ -241,10 +248,14 @@ public function handleConnectedSocks(ConnectionInterface $stream, $host, $port, $stream->removeListener('close', $onClose); $deferred->resolve($stream); - }, function (Exception $error) use ($deferred, $stream) { + }, function (Exception $error) use ($deferred, $stream, $uri) { // pass custom RuntimeException through as-is, otherwise wrap in protocol error if (!$error instanceof RuntimeException) { - $error = new RuntimeException('Invalid response received from proxy (EBADMSG)', defined('SOCKET_EBADMSG') ? SOCKET_EBADMSG: 71, $error); + $error = new RuntimeException( + 'Connection to ' . $uri . ' failed because proxy returned invalid response (EBADMSG)', + defined('SOCKET_EBADMSG') ? SOCKET_EBADMSG: 71, + $error + ); } $deferred->reject($error); @@ -252,7 +263,7 @@ public function handleConnectedSocks(ConnectionInterface $stream, $host, $port, }); } - private function handleSocks4(ConnectionInterface $stream, $host, $port, StreamReader $reader) + private function handleSocks4(ConnectionInterface $stream, $host, $port, StreamReader $reader, $uri) { // do not resolve hostname. only try to convert to IP $ip = ip2long($host); @@ -272,17 +283,20 @@ private function handleSocks4(ConnectionInterface $stream, $host, $port, StreamR 'status' => 'C', 'port' => 'n', 'ip' => 'N' - ))->then(function ($data) { + ))->then(function ($data) use ($uri) { if ($data['null'] !== 0x00) { throw new Exception('Invalid SOCKS response'); } if ($data['status'] !== 0x5a) { - throw new RuntimeException('Proxy refused connection with SOCKS error code ' . sprintf('0x%02X', $data['status']) . ' (ECONNREFUSED)', defined('SOCKET_ECONNREFUSED') ? SOCKET_ECONNREFUSED : 111); + throw new RuntimeException( + 'Connection to ' . $uri . ' failed because proxy refused connection with error code ' . sprintf('0x%02X', $data['status']) . ' (ECONNREFUSED)', + defined('SOCKET_ECONNREFUSED') ? SOCKET_ECONNREFUSED : 111 + ); } }); } - private function handleSocks5(ConnectionInterface $stream, $host, $port, StreamReader $reader) + private function handleSocks5(ConnectionInterface $stream, $host, $port, StreamReader $reader, $uri) { // protocol version 5 $data = pack('C', 0x05); @@ -302,7 +316,7 @@ private function handleSocks5(ConnectionInterface $stream, $host, $port, StreamR return $reader->readBinary(array( 'version' => 'C', 'method' => 'C' - ))->then(function ($data) use ($auth, $stream, $reader) { + ))->then(function ($data) use ($auth, $stream, $reader, $uri) { if ($data['version'] !== 0x05) { throw new Exception('Version/Protocol mismatch'); } @@ -314,14 +328,20 @@ private function handleSocks5(ConnectionInterface $stream, $host, $port, StreamR return $reader->readBinary(array( 'version' => 'C', 'status' => 'C' - ))->then(function ($data) { + ))->then(function ($data) use ($uri) { if ($data['version'] !== 0x01 || $data['status'] !== 0x00) { - throw new RuntimeException('Username/Password authentication failed (EACCES)', defined('SOCKET_EACCES') ? SOCKET_EACCES : 13); + throw new RuntimeException( + 'Connection to ' . $uri . ' failed because proxy denied access with given authentication details (EACCES)', + defined('SOCKET_EACCES') ? SOCKET_EACCES : 13 + ); } }); } else if ($data['method'] !== 0x00) { // any other method than "no authentication" - throw new RuntimeException('No acceptable authentication method found (EACCES)', defined('SOCKET_EACCES') ? SOCKET_EACCES : 13); + throw new RuntimeException( + 'Connection to ' . $uri . ' failed because proxy denied access due to unsupported authentication method (EACCES)', + defined('SOCKET_EACCES') ? SOCKET_EACCES : 13 + ); } })->then(function () use ($stream, $reader, $host, $port) { // do not resolve hostname. only try to convert to (binary/packed) IP @@ -345,7 +365,7 @@ private function handleSocks5(ConnectionInterface $stream, $host, $port, StreamR 'null' => 'C', 'type' => 'C' )); - })->then(function ($data) use ($reader) { + })->then(function ($data) use ($reader, $uri) { if ($data['version'] !== 0x05 || $data['null'] !== 0x00) { throw new Exception('Invalid SOCKS response'); } @@ -353,24 +373,51 @@ private function handleSocks5(ConnectionInterface $stream, $host, $port, StreamR // map limited list of SOCKS error codes to common socket error conditions // @link https://tools.ietf.org/html/rfc1928#section-6 if ($data['status'] === Server::ERROR_GENERAL) { - throw new RuntimeException('SOCKS server reported a general server failure (ECONNREFUSED)', defined('SOCKET_ECONNREFUSED') ? SOCKET_ECONNREFUSED : 111); + throw new RuntimeException( + 'Connection to ' . $uri . ' failed because proxy refused connection with general server failure (ECONNREFUSED)', + defined('SOCKET_ECONNREFUSED') ? SOCKET_ECONNREFUSED : 111 + ); } elseif ($data['status'] === Server::ERROR_NOT_ALLOWED_BY_RULESET) { - throw new RuntimeException('SOCKS server reported connection is not allowed by ruleset (EACCES)', defined('SOCKET_EACCES') ? SOCKET_EACCES : 13); + throw new RuntimeException( + 'Connection to ' . $uri . ' failed because proxy denied access due to ruleset (EACCES)', + defined('SOCKET_EACCES') ? SOCKET_EACCES : 13 + ); } elseif ($data['status'] === Server::ERROR_NETWORK_UNREACHABLE) { - throw new RuntimeException('SOCKS server reported network unreachable (ENETUNREACH)', defined('SOCKET_ENETUNREACH') ? SOCKET_ENETUNREACH : 101); + throw new RuntimeException( + 'Connection to ' . $uri . ' failed because proxy reported network unreachable (ENETUNREACH)', + defined('SOCKET_ENETUNREACH') ? SOCKET_ENETUNREACH : 101 + ); } elseif ($data['status'] === Server::ERROR_HOST_UNREACHABLE) { - throw new RuntimeException('SOCKS server reported host unreachable (EHOSTUNREACH)', defined('SOCKET_EHOSTUNREACH') ? SOCKET_EHOSTUNREACH : 113); + throw new RuntimeException( + 'Connection to ' . $uri . ' failed because proxy reported host unreachable (EHOSTUNREACH)', + defined('SOCKET_EHOSTUNREACH') ? SOCKET_EHOSTUNREACH : 113 + ); } elseif ($data['status'] === Server::ERROR_CONNECTION_REFUSED) { - throw new RuntimeException('SOCKS server reported connection refused (ECONNREFUSED)', defined('SOCKET_ECONNREFUSED') ? SOCKET_ECONNREFUSED : 111); + throw new RuntimeException( + 'Connection to ' . $uri . ' failed because proxy reported connection refused (ECONNREFUSED)', + defined('SOCKET_ECONNREFUSED') ? SOCKET_ECONNREFUSED : 111 + ); } elseif ($data['status'] === Server::ERROR_TTL) { - throw new RuntimeException('SOCKS server reported TTL/timeout expired (ETIMEDOUT)', defined('SOCKET_ETIMEDOUT') ? SOCKET_ETIMEDOUT : 110); + throw new RuntimeException( + 'Connection to ' . $uri . ' failed because proxy reported TTL/timeout expired (ETIMEDOUT)', + defined('SOCKET_ETIMEDOUT') ? SOCKET_ETIMEDOUT : 110 + ); } elseif ($data['status'] === Server::ERROR_COMMAND_UNSUPPORTED) { - throw new RuntimeException('SOCKS server does not support the CONNECT command (EPROTO)', defined('SOCKET_EPROTO') ? SOCKET_EPROTO : 71); + throw new RuntimeException( + 'Connection to ' . $uri . ' failed because proxy does not support the CONNECT command (EPROTO)', + defined('SOCKET_EPROTO') ? SOCKET_EPROTO : 71 + ); } elseif ($data['status'] === Server::ERROR_ADDRESS_UNSUPPORTED) { - throw new RuntimeException('SOCKS server does not support this address type (EPROTO)', defined('SOCKET_EPROTO') ? SOCKET_EPROTO : 71); + throw new RuntimeException( + 'Connection to ' . $uri . ' failed because proxy does not support this address type (EPROTO)', + defined('SOCKET_EPROTO') ? SOCKET_EPROTO : 71 + ); } - throw new RuntimeException('SOCKS server reported an unassigned error code ' . sprintf('0x%02X', $data['status']) . ' (ECONNREFUSED)', defined('SOCKET_ECONNREFUSED') ? SOCKET_ECONNREFUSED : 111); + throw new RuntimeException( + 'Connection to ' . $uri . ' failed because proxy server refused connection with unknown error code ' . sprintf('0x%02X', $data['status']) . ' (ECONNREFUSED)', + defined('SOCKET_ECONNREFUSED') ? SOCKET_ECONNREFUSED : 111 + ); } if ($data['type'] === 0x01) { // IPv4 address => skip IP and port diff --git a/tests/ClientTest.php b/tests/ClientTest.php index 4f13f57..2ddc7a0 100644 --- a/tests/ClientTest.php +++ b/tests/ClientTest.php @@ -168,7 +168,11 @@ public function testConnectorRejectsWillRejectConnection() $promise = $this->client->connect('google.com:80'); - $promise->then(null, $this->expectCallableOnceWithExceptionCode(SOCKET_ECONNREFUSED)); + $promise->then(null, $this->expectCallableOnceWithException( + 'RuntimeException', + 'Connection to tcp://google.com:80 failed because connection to proxy failed (ECONNREFUSED)', + SOCKET_ECONNREFUSED + )); } public function testCancelConnectionDuringConnectionWillCancelConnection() @@ -182,7 +186,11 @@ public function testCancelConnectionDuringConnectionWillCancelConnection() $promise = $this->client->connect('google.com:80'); $promise->cancel(); - $this->expectPromiseReject($promise); + $promise->then(null, $this->expectCallableOnceWithException( + 'RuntimeException', + 'Connection to tcp://google.com:80 cancelled while waiting for proxy (ECONNABORTED)', + SOCKET_ECONNABORTED + )); } public function testCancelConnectionDuringSessionWillCloseStream() @@ -197,7 +205,11 @@ public function testCancelConnectionDuringSessionWillCloseStream() $promise = $this->client->connect('google.com:80'); $promise->cancel(); - $promise->then(null, $this->expectCallableOnceWithExceptionCode(SOCKET_ECONNABORTED)); + $promise->then(null, $this->expectCallableOnceWithException( + 'RuntimeException', + 'Connection to tcp://google.com:80 cancelled while waiting for proxy (ECONNABORTED)', + SOCKET_ECONNABORTED + )); } public function testCancelConnectionDuringDeferredSessionWillCloseStream() @@ -213,7 +225,11 @@ public function testCancelConnectionDuringDeferredSessionWillCloseStream() $deferred->resolve($stream); $promise->cancel(); - $promise->then(null, $this->expectCallableOnceWithExceptionCode(SOCKET_ECONNABORTED)); + $promise->then(null, $this->expectCallableOnceWithException( + 'RuntimeException', + 'Connection to tcp://google.com:80 cancelled while waiting for proxy (ECONNABORTED)', + SOCKET_ECONNABORTED + )); } public function testEmitConnectionCloseDuringSessionWillRejectConnection() @@ -228,7 +244,11 @@ public function testEmitConnectionCloseDuringSessionWillRejectConnection() $stream->emit('close'); - $promise->then(null, $this->expectCallableOnceWithExceptionCode(SOCKET_ECONNRESET)); + $promise->then(null, $this->expectCallableOnceWithException( + 'RuntimeException', + 'Connection to tcp://google.com:80 failed because connection to proxy was lost while waiting for response from proxy (ECONNRESET)', + SOCKET_ECONNRESET + )); } public function testEmitConnectionErrorDuringSessionWillRejectConnection() @@ -243,7 +263,11 @@ public function testEmitConnectionErrorDuringSessionWillRejectConnection() $stream->emit('error', array(new RuntimeException())); - $promise->then(null, $this->expectCallableOnceWithExceptionCode(SOCKET_EIO)); + $promise->then(null, $this->expectCallableOnceWithException( + 'RuntimeException', + 'Connection to tcp://google.com:80 failed because connection to proxy caused a stream error (EIO)', + SOCKET_EIO + )); } public function testEmitInvalidSocks4DataDuringSessionWillRejectConnection() @@ -259,7 +283,11 @@ public function testEmitInvalidSocks4DataDuringSessionWillRejectConnection() $stream->emit('data', array("HTTP/1.1 400 Bad Request\r\n\r\n")); - $promise->then(null, $this->expectCallableOnceWithExceptionCode(SOCKET_EBADMSG)); + $promise->then(null, $this->expectCallableOnceWithException( + 'RuntimeException', + 'Connection to tcp://google.com:80 failed because proxy returned invalid response (EBADMSG)', + SOCKET_EBADMSG + )); } public function testEmitInvalidSocks5DataDuringSessionWillRejectConnection() @@ -277,7 +305,11 @@ public function testEmitInvalidSocks5DataDuringSessionWillRejectConnection() $stream->emit('data', array("HTTP/1.1 400 Bad Request\r\n\r\n")); - $promise->then(null, $this->expectCallableOnceWithExceptionCode(SOCKET_EBADMSG)); + $promise->then(null, $this->expectCallableOnceWithException( + 'RuntimeException', + 'Connection to tcp://google.com:80 failed because proxy returned invalid response (EBADMSG)', + SOCKET_EBADMSG + )); } public function testEmitSocks5DataErrorDuringSessionWillRejectConnection() @@ -295,7 +327,55 @@ public function testEmitSocks5DataErrorDuringSessionWillRejectConnection() $stream->emit('data', array("\x05\x00" . "\x05\x01\x00\x00")); - $promise->then(null, $this->expectCallableOnceWithExceptionCode(SOCKET_ECONNREFUSED)); + $promise->then(null, $this->expectCallableOnceWithException( + 'RuntimeException', + 'Connection to tcp://google.com:80 failed because proxy refused connection with general server failure (ECONNREFUSED)', + SOCKET_ECONNREFUSED + )); + } + + public function testEmitSocks5DataInvalidAuthenticationMethodWillRejectConnection() + { + $stream = $this->getMockBuilder('React\Socket\Connection')->disableOriginalConstructor()->setMethods(array('write', 'close'))->getMock(); + $stream->expects($this->once())->method('close'); + + $promise = \React\Promise\resolve($stream); + + $this->connector->expects($this->once())->method('connect')->with('127.0.0.1:1080?hostname=google.com')->willReturn($promise); + + $this->client = new Client('socks5://127.0.0.1:1080', $this->connector); + + $promise = $this->client->connect('google.com:80'); + + $stream->emit('data', array("\x05\x01")); + + $promise->then(null, $this->expectCallableOnceWithException( + 'RuntimeException', + 'Connection to tcp://google.com:80 failed because proxy denied access due to unsupported authentication method (EACCES)', + SOCKET_EACCES + )); + } + + public function testEmitSocks5DataInvalidAuthenticationDetailsWillRejectConnection() + { + $stream = $this->getMockBuilder('React\Socket\Connection')->disableOriginalConstructor()->setMethods(array('write', 'close'))->getMock(); + $stream->expects($this->once())->method('close'); + + $promise = \React\Promise\resolve($stream); + + $this->connector->expects($this->once())->method('connect')->with('127.0.0.1:1080?hostname=google.com')->willReturn($promise); + + $this->client = new Client('socks5://user:pass@127.0.0.1:1080', $this->connector); + + $promise = $this->client->connect('google.com:80'); + + $stream->emit('data', array("\x05\x02" . "\x01\x01")); + + $promise->then(null, $this->expectCallableOnceWithException( + 'RuntimeException', + 'Connection to tcp://google.com:80 failed because proxy denied access with given authentication details (EACCES)', + SOCKET_EACCES + )); } public function testEmitSocks5DataInvalidAddressTypeWillRejectConnection() @@ -313,7 +393,33 @@ public function testEmitSocks5DataInvalidAddressTypeWillRejectConnection() $stream->emit('data', array("\x05\x00" . "\x05\x00\x00\x00")); - $promise->then(null, $this->expectCallableOnceWithExceptionCode(SOCKET_EBADMSG)); + $promise->then(null, $this->expectCallableOnceWithException( + 'RuntimeException', + 'Connection to tcp://google.com:80 failed because proxy returned invalid response (EBADMSG)', + SOCKET_EBADMSG + )); + } + + public function testEmitSocks4DataInvalidResponseWillRejectConnection() + { + $stream = $this->getMockBuilder('React\Socket\Connection')->disableOriginalConstructor()->setMethods(array('write', 'close'))->getMock(); + $stream->expects($this->once())->method('close'); + + $promise = \React\Promise\resolve($stream); + + $this->connector->expects($this->once())->method('connect')->with('127.0.0.1:1080?hostname=google.com')->willReturn($promise); + + $this->client = new Client('socks4://127.0.0.1:1080', $this->connector); + + $promise = $this->client->connect('google.com:80'); + + $stream->emit('data', array("\x00\x55" . "\x00\x00" . "\x00\x00\x00\x00")); + + $promise->then(null, $this->expectCallableOnceWithException( + 'RuntimeException', + 'Connection to tcp://google.com:80 failed because proxy refused connection with error code 0x55 (ECONNREFUSED)', + SOCKET_ECONNREFUSED + )); } public function testEmitSocks5DataIpv6AddressWillResolveConnection() @@ -357,49 +463,59 @@ public function provideConnectionErrors() return array( array( Server::ERROR_GENERAL, - SOCKET_ECONNREFUSED + SOCKET_ECONNREFUSED, + 'failed because proxy refused connection with general server failure (ECONNREFUSED)' ), array( Server::ERROR_NOT_ALLOWED_BY_RULESET, - SOCKET_EACCES + SOCKET_EACCES, + 'failed because proxy denied access due to ruleset (EACCES)' ), array( Server::ERROR_NETWORK_UNREACHABLE, - SOCKET_ENETUNREACH + SOCKET_ENETUNREACH, + 'failed because proxy reported network unreachable (ENETUNREACH)' ), array( Server::ERROR_HOST_UNREACHABLE, - SOCKET_EHOSTUNREACH + SOCKET_EHOSTUNREACH, + 'failed because proxy reported host unreachable (EHOSTUNREACH)' ), array( Server::ERROR_CONNECTION_REFUSED, - SOCKET_ECONNREFUSED + SOCKET_ECONNREFUSED, + 'failed because proxy reported connection refused (ECONNREFUSED)' ), array( Server::ERROR_TTL, - SOCKET_ETIMEDOUT + SOCKET_ETIMEDOUT, + 'failed because proxy reported TTL/timeout expired (ETIMEDOUT)' ), array( Server::ERROR_COMMAND_UNSUPPORTED, - SOCKET_EPROTO + SOCKET_EPROTO, + 'failed because proxy does not support the CONNECT command (EPROTO)' ), array( Server::ERROR_ADDRESS_UNSUPPORTED, - SOCKET_EPROTO + SOCKET_EPROTO, + 'failed because proxy does not support this address type (EPROTO)' ), array( 200, - SOCKET_ECONNREFUSED + SOCKET_ECONNREFUSED, + 'failed because proxy server refused connection with unknown error code 0xC8 (ECONNREFUSED)' ) ); } /** * @dataProvider provideConnectionErrors - * @param int $error - * @param int $expectedCode + * @param int $error + * @param int $expectedCode + * @param string $expectedMessage */ - public function testEmitSocks5DataErrorMapsToExceptionCode($error, $expectedCode) + public function testEmitSocks5DataErrorMapsToExceptionCode($error, $expectedCode, $expectedMessage) { $stream = $this->getMockBuilder('React\Socket\Connection')->disableOriginalConstructor()->setMethods(array('write', 'close'))->getMock(); $stream->expects($this->once())->method('close'); @@ -414,7 +530,11 @@ public function testEmitSocks5DataErrorMapsToExceptionCode($error, $expectedCode $stream->emit('data', array("\x05\x00" . "\x05" . chr($error) . "\x00\x00")); - $promise->then(null, $this->expectCallableOnceWithExceptionCode($expectedCode)); + $promise->then(null, $this->expectCallableOnceWithException( + 'RuntimeException', + 'Connection to tcp://google.com:80 ' . $expectedMessage, + $expectedCode + )); } public function testConnectionErrorShouldNotCreateGarbageCycles() diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 029c6b9..e320699 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -8,7 +8,6 @@ protected function expectCallableOnce() { $mock = $this->createCallableMock(); - if (func_num_args() > 0) { $mock ->expects($this->once()) @@ -23,40 +22,38 @@ protected function expectCallableOnce() return $mock; } - protected function expectCallableNever() + protected function expectCallableOnceWith($arg) { $mock = $this->createCallableMock(); - $mock - ->expects($this->never()) - ->method('__invoke'); - return $mock; - } - - protected function expectCallableOnceWithExceptionCode($code) - { - $mock = $this->createCallableMock(); $mock ->expects($this->once()) ->method('__invoke') - ->with($this->callback(function ($e) use ($code) { - return $e->getCode() === $code; - })); + ->with($arg); return $mock; } - protected function expectCallableOnceParameter($type) + protected function expectCallableNever() { $mock = $this->createCallableMock(); $mock - ->expects($this->once()) - ->method('__invoke') - ->with($this->isInstanceOf($type)); + ->expects($this->never()) + ->method('__invoke'); return $mock; } + protected function expectCallableOnceWithException($class, $message, $code) + { + return $this->expectCallableOnceWith($this->logicalAnd( + $this->isInstanceOf($class), + $this->callback(function (\Exception $e) use ($message, $code) { + return strpos($e->getMessage(), $message) !== false && $e->getCode() === $code; + }) + )); + } + /** * @link https://github.com/reactphp/react/blob/master/tests/React/Tests/Socket/TestCase.php (taken from reactphp/react) */