diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml index 09b3864..43487b1 100644 --- a/.github/workflows/dependabot-auto-merge.yml +++ b/.github/workflows/dependabot-auto-merge.yml @@ -10,13 +10,13 @@ jobs: runs-on: ubuntu-latest if: ${{ github.actor == 'dependabot[bot]' }} steps: - + - name: Dependabot metadata id: metadata uses: dependabot/fetch-metadata@v2.5.0 with: github-token: "${{ secrets.GITHUB_TOKEN }}" - + - name: Auto-merge Dependabot PRs for semver-minor/patch updates if: ${{steps.metadata.outputs.update-type != 'version-update:semver-major'}} run: gh pr merge --auto --merge "$PR_URL" diff --git a/.github/workflows/fix-php-code-style-issues.yml b/.github/workflows/fix-php-code-style-issues.yml index 2484fbe..821b506 100644 --- a/.github/workflows/fix-php-code-style-issues.yml +++ b/.github/workflows/fix-php-code-style-issues.yml @@ -2,6 +2,8 @@ name: Fix PHP code style issues on: push: + branches: + - main paths: - '**.php' diff --git a/README.md b/README.md index 3217998..589f017 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,9 @@ Simplify the integration of the Bitkub API into your PHP application. [Bitkub API Documentation](https://github.com/bitkub/bitkub-official-api-docs/blob/master/restful-api.md) -**Notes +**Requirements:** PHP 8.2+ + +**Notes** We are not affiliated, associated, authorized, endorsed by, or in any way officially connected with Bitkub, or any of its subsidiaries or its affiliates. ## Installation @@ -43,27 +45,26 @@ $myBTC = $response->json('result.BTC.available'); echo "My BTC balance: {$myBTC}"; ``` -Websocket API +WebSocket API ```php +$client = \Farzai\Bitkub\ClientBuilder::create() + ->setCredentials('YOUR_API_KEY', 'YOUR_SECRET_KEY') + ->build(); -$websocket = new \Farzai\Bitkub\WebSocket\Endpoints\MarketEndpoint( - new \Farzai\Bitkub\WebSocketClient( - \Farzai\Bitkub\ClientBuilder::create() - ->setCredentials('YOUR_API_KEY', 'YOUR_SECRET_KEY') - ->build(), - ), -); +$websocket = \Farzai\Bitkub\WebSocketClientBuilder::create() + ->setClient($client) + ->build(); -$websocket->listen('trade.thb_ada', function (\Farzai\Bitkub\WebSocket\Message $message) { +$websocket->market()->listen('trade.thb_ada', function (\Farzai\Bitkub\WebSocket\Message $message) { // Do something - echo $message->sym.PHP_EOL; + echo $message->sym . PHP_EOL; }); // Or you can use multiple symbols like this -$websocket->listen(['trade.thb_ada', 'trade.thb_btc', function (\Farzai\Bitkub\WebSocket\Message $message) { +$websocket->market()->listen(['trade.thb_ada', 'trade.thb_btc'], function (\Farzai\Bitkub\WebSocket\Message $message) { // Do something - echo $message->sym.PHP_EOL; + echo $message->sym . PHP_EOL; }); $websocket->run(); @@ -104,6 +105,9 @@ $websocket->run(); - [User](#user) - [Check trading credit balance.](#check-trading-credit-balance) - [Check deposit/withdraw limitations and usage.](#check-depositwithdraw-limitations-and-usage) + - [WebSocket](#websocket) + - [Market streams](#market-streams) + - [Live order book](#live-order-book) - [Testing](#testing) - [Changelog](#changelog) - [Contributing](#contributing) @@ -112,7 +116,7 @@ $websocket->run(); - [License](#license) ### Market -Call the market endpoint. +Call the market endpoint. This will return an instance of `Farzai\Bitkub\Endpoints\MarketEndpoint` class. ```php @@ -192,7 +196,7 @@ $market->books([ ``` #### Get user available balances -- GET `/api/market/wallet` +- POST `/api/v3/market/wallet` ```php $market->wallet(); ``` @@ -351,7 +355,7 @@ $crypto->addresses([ $crypto->withdrawal([ // string: Currency for withdrawal (e.g. BTC, ETH) 'cur' => 'BTC', - + // float: Amount you want to withdraw 'amt' => 0.001, @@ -474,6 +478,54 @@ $user->tradingCredits(); $user->limits(); ``` +### WebSocket + +Create a WebSocket client using the `WebSocketClientBuilder`. + +```php +$client = \Farzai\Bitkub\ClientBuilder::create() + ->setCredentials('YOUR_API_KEY', 'YOUR_SECRET_KEY') + ->build(); + +$websocket = \Farzai\Bitkub\WebSocketClientBuilder::create() + ->setClient($client) + ->build(); +``` + +#### Market streams + +Subscribe to market data streams (trades, tickers, etc.). + +```php +$websocket->market()->listen('trade.thb_btc', function (\Farzai\Bitkub\WebSocket\Message $message) { + echo $message->sym . PHP_EOL; +}); + +// Or subscribe to multiple streams at once +$websocket->market()->listen(['trade.thb_btc', 'trade.thb_eth'], function (\Farzai\Bitkub\WebSocket\Message $message) { + echo $message->sym . PHP_EOL; +}); + +$websocket->run(); +``` + +#### Live order book + +Subscribe to live order book updates. Requires a REST client to resolve symbol names. + +```php +$websocket->liveOrderBook()->listen('THB_BTC', function (\Farzai\Bitkub\WebSocket\Message $message) { + echo $message . PHP_EOL; +}); + +// Or use a numeric symbol ID directly +$websocket->liveOrderBook()->listen(1, function (\Farzai\Bitkub\WebSocket\Message $message) { + echo $message . PHP_EOL; +}); + +$websocket->run(); +``` + --- ## Testing diff --git a/composer.json b/composer.json index 53881db..038d124 100644 --- a/composer.json +++ b/composer.json @@ -47,6 +47,6 @@ "phpstan/extension-installer": true } }, - "minimum-stability": "dev", + "minimum-stability": "stable", "prefer-stable": true } diff --git a/src/Client.php b/src/Client.php index 83a3cc0..c025070 100644 --- a/src/Client.php +++ b/src/Client.php @@ -1,5 +1,7 @@ logger = $logger; } + private ?Endpoints\MarketEndpoint $marketEndpoint = null; + + private ?Endpoints\CryptoEndpoint $cryptoEndpoint = null; + + private ?Endpoints\UserEndpoint $userEndpoint = null; + + private ?Endpoints\SystemEndpoint $systemEndpoint = null; + public function market(): Endpoints\MarketEndpoint { - return new Endpoints\MarketEndpoint($this); + return $this->marketEndpoint ??= new Endpoints\MarketEndpoint($this); } public function crypto(): Endpoints\CryptoEndpoint { - return new Endpoints\CryptoEndpoint($this); + return $this->cryptoEndpoint ??= new Endpoints\CryptoEndpoint($this); } public function user(): Endpoints\UserEndpoint { - return new Endpoints\UserEndpoint($this); + return $this->userEndpoint ??= new Endpoints\UserEndpoint($this); } public function system(): Endpoints\SystemEndpoint { - return new Endpoints\SystemEndpoint($this); + return $this->systemEndpoint ??= new Endpoints\SystemEndpoint($this); } public function getTransport(): Transport diff --git a/src/ClientBuilder.php b/src/ClientBuilder.php index 129970a..3943c12 100644 --- a/src/ClientBuilder.php +++ b/src/ClientBuilder.php @@ -1,5 +1,7 @@ config = array_merge($this->config, [ 'api_key' => $apiKey, @@ -64,7 +64,7 @@ public function setCredentials(string $apiKey, string $secretKey) * * @return $this */ - public function setHttpClient(PsrClientInterface $httpClient) + public function setHttpClient(PsrClientInterface $httpClient): static { $this->httpClient = $httpClient; @@ -76,14 +76,14 @@ public function setHttpClient(PsrClientInterface $httpClient) * * @return $this */ - public function setLogger(PsrLoggerInterface $logger) + public function setLogger(PsrLoggerInterface $logger): static { $this->logger = $logger; return $this; } - public function setRetries(int $retries) + public function setRetries(int $retries): static { if ($retries < 0) { throw new \InvalidArgumentException('Retries must be greater than or equal to 0.'); @@ -94,7 +94,7 @@ public function setRetries(int $retries) return $this; } - public function build() + public function build(): Client { $this->ensureConfigIsValid(); diff --git a/src/Constants/ErrorCodes.php b/src/Constants/ErrorCodes.php index df1fa04..235c839 100644 --- a/src/Constants/ErrorCodes.php +++ b/src/Constants/ErrorCodes.php @@ -1,5 +1,7 @@ > $listeners + */ public function handle(array $listeners): void; } diff --git a/src/Endpoints/AbstractEndpoint.php b/src/Endpoints/AbstractEndpoint.php index 3590df4..a7ab6d5 100644 --- a/src/Endpoints/AbstractEndpoint.php +++ b/src/Endpoints/AbstractEndpoint.php @@ -1,5 +1,7 @@ client = $client; } + protected function filterParams(array $params): array + { + return array_filter($params, fn ($value) => $value !== null && $value !== ''); + } + protected function makeRequest(string $method, string $path, array $options = []): PendingRequest { return new PendingRequest($this->client, $method, $path, $options); diff --git a/src/Endpoints/CryptoEndpoint.php b/src/Endpoints/CryptoEndpoint.php index 9c7f197..356e2f7 100644 --- a/src/Endpoints/CryptoEndpoint.php +++ b/src/Endpoints/CryptoEndpoint.php @@ -1,5 +1,7 @@ client->getConfig(); return $this->makeRequest('GET', '/api/v3/crypto/addresses') - ->withQuery(array_filter($params)) + ->withQuery($this->filterParams($params)) ->acceptJson() ->withInterceptor(new GenerateSignatureV3($config, $this->client)) ->send(); @@ -173,7 +175,7 @@ public function depositHistory(array $params): ResponseInterface $config = $this->client->getConfig(); return $this->makeRequest('POST', '/api/v3/crypto/deposit-history') - ->withQuery(array_filter($params)) + ->withQuery($this->filterParams($params)) ->acceptJson() ->withInterceptor(new GenerateSignatureV3($config, $this->client)) ->send(); @@ -216,7 +218,7 @@ public function withdrawalHistory(array $params): ResponseInterface $config = $this->client->getConfig(); return $this->makeRequest('POST', '/api/v3/crypto/withdrawal-history') - ->withQuery(array_filter($params)) + ->withQuery($this->filterParams($params)) ->acceptJson() ->withInterceptor(new GenerateSignatureV3($config, $this->client)) ->send(); diff --git a/src/Endpoints/MarketEndpoint.php b/src/Endpoints/MarketEndpoint.php index 6abc414..f70644e 100644 --- a/src/Endpoints/MarketEndpoint.php +++ b/src/Endpoints/MarketEndpoint.php @@ -1,5 +1,7 @@ makeRequest('GET', '/api/market/ticker') - ->withQuery(array_filter([ + ->withQuery($this->filterParams([ 'sym' => $symbol, ])) ->send(); @@ -98,7 +100,7 @@ public function ticker(?string $symbol = null): ResponseInterface public function trades(array $params): ResponseInterface { return $this->makeRequest('GET', '/api/market/trades') - ->withQuery(array_filter($params)) + ->withQuery($this->filterParams($params)) ->send(); } @@ -130,7 +132,7 @@ public function trades(array $params): ResponseInterface public function bids(array $params): ResponseInterface { return $this->makeRequest('GET', '/api/market/bids') - ->withQuery(array_filter($params)) + ->withQuery($this->filterParams($params)) ->send(); } @@ -162,7 +164,7 @@ public function bids(array $params): ResponseInterface public function asks(array $params): ResponseInterface { return $this->makeRequest('GET', '/api/market/asks') - ->withQuery(array_filter($params)) + ->withQuery($this->filterParams($params)) ->send(); } @@ -205,7 +207,7 @@ public function asks(array $params): ResponseInterface public function books(array $params): ResponseInterface { return $this->makeRequest('GET', '/api/market/books') - ->withQuery(array_filter($params)) + ->withQuery($this->filterParams($params)) ->send(); } @@ -418,11 +420,9 @@ public function openOrders(string $sym): ResponseInterface $config = $this->client->getConfig(); return $this->makeRequest('GET', '/api/v3/market/my-open-orders') + ->withQuery(['sym' => $sym]) ->acceptJson() ->withInterceptor(new GenerateSignatureV3($config, $this->client)) - ->withBody([ - 'sym' => $sym, - ]) ->send(); } @@ -471,7 +471,7 @@ public function myOrderHistory(array $params): ResponseInterface $config = $this->client->getConfig(); return $this->makeRequest('GET', '/api/v3/market/my-order-history') - ->withQuery(array_filter($params)) + ->withQuery($this->filterParams($params)) ->acceptJson() ->withInterceptor(new GenerateSignatureV3($config, $this->client)) ->send(); @@ -529,7 +529,7 @@ public function myOrderInfo(array $params): ResponseInterface $config = $this->client->getConfig(); return $this->makeRequest('GET', '/api/v3/market/order-info') - ->withQuery(array_filter($params)) + ->withQuery($this->filterParams($params)) ->acceptJson() ->withInterceptor(new GenerateSignatureV3($config, $this->client)) ->send(); diff --git a/src/Endpoints/SystemEndpoint.php b/src/Endpoints/SystemEndpoint.php index cee5691..6943f3a 100644 --- a/src/Endpoints/SystemEndpoint.php +++ b/src/Endpoints/SystemEndpoint.php @@ -1,5 +1,7 @@ client); - - $timestamp = (int) $endpoint->serverTimestamp()->throw()->body(); + $timestamp = $this->getTimestamp(); $method = strtoupper($request->getMethod()); $path = '/'.trim($request->getUri()->getPath(), '/'); - $payload = $request->getBody()->getContents() ?: ''; + + $body = $request->getBody(); + $payload = $body->getContents() ?: ''; + $body->rewind(); $query = $request->getUri()->getQuery(); if (! empty($query)) { @@ -53,6 +62,20 @@ public function apply(PsrRequestInterface $request): PsrRequestInterface return $request->withHeader('X-BTK-APIKEY', $this->config['api_key']) ->withHeader('X-BTK-SIGN', $signature) - ->withHeader('X-BTK-TIMESTAMP', $timestamp); + ->withHeader('X-BTK-TIMESTAMP', (string) $timestamp); + } + + private function getTimestamp(): int + { + $now = (int) (microtime(true) * 1000); + + if ($this->serverTimeDriftMs === null || (microtime(true) - $this->lastSyncTime) > self::SYNC_INTERVAL_SECONDS) { + $endpoint = new SystemEndpoint($this->client); + $serverTime = (int) $endpoint->serverTimestamp()->throw()->body(); + $this->serverTimeDriftMs = $serverTime - $now; + $this->lastSyncTime = microtime(true); + } + + return $now + $this->serverTimeDriftMs; } } diff --git a/src/Requests/PendingRequest.php b/src/Requests/PendingRequest.php index dfdfcfe..df5a040 100644 --- a/src/Requests/PendingRequest.php +++ b/src/Requests/PendingRequest.php @@ -1,5 +1,7 @@ + * @var array */ - public $interceptors = []; + private array $interceptors = []; /** * Create a new pending request instance. @@ -42,7 +44,7 @@ public function __construct(ClientInterface $client, string $method, string $pat /** * Set the request method. */ - public function method(string $method) + public function method(string $method): static { $this->method = $method; @@ -52,7 +54,7 @@ public function method(string $method) /** * Set the request path. */ - public function path(string $path) + public function path(string $path): static { $this->path = $path; @@ -62,14 +64,14 @@ public function path(string $path) /** * Set the request options. */ - public function options(array $options) + public function options(array $options): static { $this->options = $options; return $this; } - public function withInterceptor(RequestInterceptor $interceptor) + public function withInterceptor(RequestInterceptor $interceptor): static { $this->interceptors[] = $interceptor; @@ -79,7 +81,7 @@ public function withInterceptor(RequestInterceptor $interceptor) /** * Set the request body. */ - public function withBody(mixed $body) + public function withBody(mixed $body): static { $this->options['body'] = $body; @@ -89,7 +91,7 @@ public function withBody(mixed $body) /** * Set the request query. */ - public function withQuery(array $query) + public function withQuery(array $query): static { $this->options['query'] = $query; @@ -99,26 +101,26 @@ public function withQuery(array $query) /** * Set the request headers. */ - public function withHeaders(array $headers) + public function withHeaders(array $headers): static { $this->options['headers'] = array_merge($this->options['headers'] ?? [], $headers); return $this; } - public function withHeader(string $key, string $value) + public function withHeader(string $key, string $value): static { $this->options['headers'][$key] = $value; return $this; } - public function acceptJson() + public function acceptJson(): static { return $this->withHeader('Accept', 'application/json'); } - public function asJson() + public function asJson(): static { return $this->withHeader('Content-Type', 'application/json'); } diff --git a/src/Responses/AbstractResponseDecorator.php b/src/Responses/AbstractResponseDecorator.php index 5b0be0e..96bfdae 100644 --- a/src/Responses/AbstractResponseDecorator.php +++ b/src/Responses/AbstractResponseDecorator.php @@ -1,5 +1,7 @@ json('error'); if ($errorCode !== null && $errorCode !== 0) { throw new BitkubResponseErrorCodeException($response); } - return $callback ? $callback($response, $e) : $response; + if ($callback) { + return $callback($response, $e); + } + + return $response; }); return $this; diff --git a/src/UriFactory.php b/src/UriFactory.php index cb38704..2eb78ca 100644 --- a/src/UriFactory.php +++ b/src/UriFactory.php @@ -1,5 +1,7 @@ websocket->run(); } - protected function getLogger() + protected function getLogger(): \Psr\Log\LoggerInterface { return $this->websocket->getLogger(); } diff --git a/src/WebSocket/Endpoints/LiveOrderBookEndpoint.php b/src/WebSocket/Endpoints/LiveOrderBookEndpoint.php index 7900050..408d8ac 100644 --- a/src/WebSocket/Endpoints/LiveOrderBookEndpoint.php +++ b/src/WebSocket/Endpoints/LiveOrderBookEndpoint.php @@ -1,11 +1,16 @@ |null */ + private ?array $symbolMap = null; + /** * Add event listener. * @@ -14,36 +19,40 @@ class LiveOrderBookEndpoint extends AbstractEndpoint * }); * * @param string|int $symbol Symbol name or id. - * @param callable|array $listeners + * @param callable|array $listeners */ - public function listen($symbol, $listeners) + public function listen(string|int $symbol, callable|array $listeners): static { - // Check if symbol is numeric. if (! is_numeric($symbol)) { + $symbol = $this->resolveSymbolId((string) $symbol); + } - $this->getLogger()->debug('Find symbol id by name: '.$symbol); - - // Find symbol id by name. - $market = new RestApiEndpoints\MarketEndpoint($this->websocket->getClient()); + $this->websocket->addListener('orderbook/'.$symbol, $listeners); - foreach ($market->symbols()->throw()->json('result') as $item) { - if ($item['symbol'] === strtoupper(trim($symbol))) { - $symbol = $item['id']; + return $this; + } - $this->getLogger()->debug('Found symbol id: '.$symbol); - break; - } - } + private function resolveSymbolId(string $symbol): int + { + $client = $this->websocket->getClient(); + if ($client === null) { + throw new \RuntimeException('A REST client is required to resolve symbol names. Use numeric symbol IDs or set a client via WebSocketClientBuilder::setClient().'); + } - if (! is_numeric($symbol)) { - $this->getLogger()->debug('Invalid symbol name. Given: '.$symbol); + if ($this->symbolMap === null) { + $this->symbolMap = []; + $market = new RestApiEndpoints\MarketEndpoint($client); - throw new \InvalidArgumentException('Invalid symbol name. Given: '.$symbol); + foreach ($market->symbols()->throw()->json('result') as $item) { + $this->symbolMap[strtoupper($item['symbol'])] = $item['id']; } } - $this->websocket->addListener('orderbook/'.$symbol, $listeners); + $key = strtoupper(trim($symbol)); + if (! isset($this->symbolMap[$key])) { + throw new \InvalidArgumentException('Invalid symbol name. Given: '.$symbol); + } - return $this; + return $this->symbolMap[$key]; } } diff --git a/src/WebSocket/Endpoints/MarketEndpoint.php b/src/WebSocket/Endpoints/MarketEndpoint.php index 2525e3a..adb45ca 100644 --- a/src/WebSocket/Endpoints/MarketEndpoint.php +++ b/src/WebSocket/Endpoints/MarketEndpoint.php @@ -1,5 +1,7 @@ $listeners + * @param callable|array $listeners */ - public function listen($streamNames, $listeners) + public function listen(string|array $streamNames, callable|array $listeners): static { $streamNames = $this->normalizeStreamNames($streamNames); - $this->getLogger()->debug('Add event listener for stream: '.implode(', ', $streamNames)); + $this->getLogger()->debug('Subscribing to streams: '.implode(', ', $streamNames)); foreach ($streamNames as $name) { $this->websocket->addListener($name, $listeners); @@ -31,18 +33,23 @@ public function listen($streamNames, $listeners) * Normalize stream names. * * @param string[]|string $streamNames + * @return string[] */ - private function normalizeStreamNames($streamNames): array + private function normalizeStreamNames(string|array $streamNames): array { if (is_string($streamNames)) { $streamNames = explode(',', $streamNames); } - $streamNames = array_filter(array_map('trim', $streamNames), function ($streamName) { - return ! empty($streamName); - }); + $streamNames = array_filter( + array_map('trim', $streamNames), + fn (string $name): bool => ! empty($name), + ); - $streamNames = array_map(fn ($streamName) => $this->getStreamName($streamName), $streamNames); + $streamNames = array_map( + fn (string $name): string => $this->getStreamName($name), + $streamNames, + ); return $streamNames; } @@ -51,8 +58,7 @@ private function getStreamName(string $streamName): string { $streamName = 'market.'.preg_replace('/^market\./', '', $streamName); - // Validate stream name format. - if (substr_count($streamName, '.') === 2) { + if (preg_match('/^market\.[a-z]+\.[a-z0-9_]+$/i', $streamName)) { return $streamName; } diff --git a/src/WebSocket/Engine.php b/src/WebSocket/Engine.php index 00feec9..a8769df 100644 --- a/src/WebSocket/Engine.php +++ b/src/WebSocket/Engine.php @@ -1,5 +1,7 @@ getEventNames($listeners); + $url = rtrim($this->baseUrl, '/').'/'.implode(',', $events); - $this->logger->info('[WebSocket] - Connecting to WebSocket server...'); + $attempt = 0; - $this->logger->debug('[WebSocket] - Events: '.implode(', ', $events)); + do { + if ($attempt > 0) { + $this->logger->info('[WebSocket] - Reconnecting (attempt '.$attempt.' of '.$this->reconnectAttempts.')...'); + usleep($this->reconnectDelayMs * 1000); + } - $client = new WebSocketClient('wss://api.bitkub.com/websocket-api/'.implode(',', $events)); + $this->logger->info('[WebSocket] - Connecting to WebSocket server...'); + $this->logger->debug('[WebSocket] - Events: '.implode(', ', $events)); - $client - ->addMiddleware(new WebSocketMiddleware\CloseHandler) - ->addMiddleware(new WebSocketMiddleware\PingResponder); + $client = new WebSocketClient($url); - $client->onText(function (WebSocketClient $client, WebSocketConnection $connection, WebSocketMessage $message) use ($listeners) { - $receivedAt = Carbon::now(); + $client + ->addMiddleware(new WebSocketMiddleware\CloseHandler) + ->addMiddleware(new WebSocketMiddleware\PingResponder); - $data = @json_decode($message->getContent(), true) ?? []; - if (! isset($data['stream'])) { - $this->logger->warning('[WebSocket] - Unknown data: '.$message->getContent()); + $client->onText(function (WebSocketClient $client, WebSocketConnection $connection, WebSocketMessage $message) use ($listeners) { + $receivedAt = Carbon::now(); - return; - } + $data = json_decode($message->getContent(), true); + if (! is_array($data) || ! isset($data['stream'])) { + $this->logger->warning('[WebSocket] - Received non-JSON or unknown message format.'); - $event = $data['stream']; - if (! isset($listeners[$event])) { - $this->logger->warning('[WebSocket] - Unknown event: '.$event); + return; + } - return; - } + $event = $data['stream']; + if (! isset($listeners[$event])) { + $this->logger->warning('[WebSocket] - Unknown event: '.$event); - $message = new Message( - $message->getContent(), - $receivedAt->toDateTimeImmutable(), - ); + return; + } - foreach ($listeners[$event] as $listener) { - $this->logger->info('[WebSocket] - Event: '.$event); + $wsMessage = new Message( + $message->getContent(), + $receivedAt->toDateTimeImmutable(), + $data, + ); - $listener($message); - } - }); + foreach ($listeners[$event] as $listener) { + $this->logger->debug('[WebSocket] - Event: '.$event); + + try { + $listener($wsMessage); + } catch (\Throwable $e) { + $this->logger->error('[WebSocket] - Listener error on event '.$event.': '.$e->getMessage()); + } + } + }); - $client->onClose(function () { - $this->logger->info('[WebSocket] - Connection closed.'); - }); + $client->onClose(function () { + $this->logger->info('[WebSocket] - Connection closed.'); + }); + + try { + $client->start(); + break; // Clean close — no reconnect needed + } catch (\Throwable $e) { + $this->logger->error('[WebSocket] - Connection error: '.$e->getMessage()); + } - $client->start(); + $attempt++; + } while ($attempt <= $this->reconnectAttempts); } private function getEventNames(array $listeners): array diff --git a/src/WebSocket/Message.php b/src/WebSocket/Message.php index 86f3d14..c414f50 100644 --- a/src/WebSocket/Message.php +++ b/src/WebSocket/Message.php @@ -1,5 +1,7 @@ body = $body; - $this->jsonDecoded = @json_decode($body, true) ?? false; + if ($decoded !== null) { + $this->jsonDecoded = $decoded; + } else { + $raw = json_decode($body, true); + $this->jsonDecoded = is_array($raw) ? $raw : null; + } $this->receivedAt = $receivedAt; } @@ -34,7 +41,11 @@ public function getReceivedAt(): DateTimeImmutable public function json($key = null) { if ($key === null) { - return $this->jsonDecoded ?: null; + return $this->jsonDecoded; + } + + if ($this->jsonDecoded === null) { + return null; } return Arr::get($this->jsonDecoded, $key); @@ -52,7 +63,7 @@ public function jsonSerialize(): array public function toArray(): array { - return $this->jsonDecoded; + return $this->jsonDecoded ?? []; } public function offsetExists($offset): bool @@ -67,12 +78,12 @@ public function offsetGet($offset): mixed public function offsetSet($offset, $value): void { - $this->jsonDecoded[$offset] = $value; + throw new \BadMethodCallException('Message is immutable.'); } public function offsetUnset($offset): void { - unset($this->jsonDecoded[$offset]); + throw new \BadMethodCallException('Message is immutable.'); } public function __get($name) @@ -87,11 +98,11 @@ public function __isset($name): bool public function __set($name, $value): void { - $this->jsonDecoded[$name] = $value; + throw new \BadMethodCallException('Message is immutable.'); } public function __unset($name): void { - unset($this->jsonDecoded[$name]); + throw new \BadMethodCallException('Message is immutable.'); } } diff --git a/src/WebSocketClient.php b/src/WebSocketClient.php index e6d3e66..8a6ecde 100644 --- a/src/WebSocketClient.php +++ b/src/WebSocketClient.php @@ -1,52 +1,62 @@ > + * @var array> */ private array $listeners = []; + private ?WebSocket\Endpoints\MarketEndpoint $marketEndpoint = null; + + private ?WebSocket\Endpoints\LiveOrderBookEndpoint $liveOrderBookEndpoint = null; + public function __construct( - ClientInterface $client, - ?WebSocketEngineInterface $websocket = null - ) { - $this->client = $client; - $this->websocket = $websocket ?: new WebSocket\Engine($this->getLogger()); - } + private WebSocketEngineInterface $engine, + private ?ClientInterface $client = null, + private ?LoggerInterface $logger = null, + ) {} public function getConfig(): array { - return $this->client->getConfig(); + return $this->client?->getConfig() ?? []; } public function getLogger(): LoggerInterface { - return $this->client->getLogger(); + return $this->logger ?? $this->client?->getLogger() ?? new NullLogger; } - public function getClient(): ClientInterface + public function getClient(): ?ClientInterface { return $this->client; } + public function market(): WebSocket\Endpoints\MarketEndpoint + { + return $this->marketEndpoint ??= new WebSocket\Endpoints\MarketEndpoint($this); + } + + public function liveOrderBook(): WebSocket\Endpoints\LiveOrderBookEndpoint + { + return $this->liveOrderBookEndpoint ??= new WebSocket\Endpoints\LiveOrderBookEndpoint($this); + } + /** * Add event listener. * - * @param callable|array $listener - * @return $this + * @param callable|array $listener */ - public function addListener(string $event, $listener) + public function addListener(string $event, callable|array $listener): static { if (! isset($this->listeners[$event])) { $this->listeners[$event] = []; @@ -57,13 +67,16 @@ public function addListener(string $event, $listener) return $this; } + /** + * @return array> + */ public function getListeners(): array { return $this->listeners; } - public function run() + public function run(): void { - $this->websocket->handle($this->listeners); + $this->engine->handle($this->listeners); } } diff --git a/src/WebSocketClientBuilder.php b/src/WebSocketClientBuilder.php new file mode 100644 index 0000000..3289de1 --- /dev/null +++ b/src/WebSocketClientBuilder.php @@ -0,0 +1,102 @@ +client = $client; + + return $this; + } + + public function setEngine(WebSocketEngineInterface $engine): static + { + $this->engine = $engine; + + return $this; + } + + public function setLogger(PsrLoggerInterface $logger): static + { + $this->logger = $logger; + + return $this; + } + + public function setBaseUrl(string $baseUrl): static + { + $this->baseUrl = $baseUrl; + + return $this; + } + + public function setReconnectAttempts(int $reconnectAttempts): static + { + if ($reconnectAttempts < 0) { + throw new \InvalidArgumentException('Reconnect attempts must be greater than or equal to 0.'); + } + + $this->reconnectAttempts = $reconnectAttempts; + + return $this; + } + + public function setReconnectDelayMs(int $reconnectDelayMs): static + { + if ($reconnectDelayMs < 0) { + throw new \InvalidArgumentException('Reconnect delay must be greater than or equal to 0.'); + } + + $this->reconnectDelayMs = $reconnectDelayMs; + + return $this; + } + + public function build(): WebSocketClient + { + $logger = $this->logger ?? $this->client?->getLogger() ?? new NullLogger; + + $engine = $this->engine ?? new WebSocket\Engine( + logger: $logger, + baseUrl: $this->baseUrl, + reconnectAttempts: $this->reconnectAttempts, + reconnectDelayMs: $this->reconnectDelayMs, + ); + + return new WebSocketClient( + engine: $engine, + client: $this->client, + logger: $logger, + ); + } +} diff --git a/tests/ArchTest.php b/tests/ArchTest.php index fd6a1e4..ca081af 100644 --- a/tests/ArchTest.php +++ b/tests/ArchTest.php @@ -1,5 +1,7 @@ expect(['dd', 'dump', 'ray']) ->not->toBeUsed(); diff --git a/tests/AuthorizerTest.php b/tests/AuthorizerTest.php index c2ee6fb..fd4e6f3 100644 --- a/tests/AuthorizerTest.php +++ b/tests/AuthorizerTest.php @@ -1,5 +1,7 @@ toBe('ae6fd3dc7d85ebea023e54292fa6eebaeea6dc02002433c51b57136eeb0a03e5'); }); + +it('generates different signatures with non-empty query', function () { + $secret = 'secret'; + $timestamp = 1630483200000; + $method = 'GET'; + $path = '/api/market/trades'; + + $sigWithoutQuery = Utility::generateSignature($secret, $timestamp, $method, $path, '', ''); + $sigWithQuery = Utility::generateSignature($secret, $timestamp, $method, $path, '?sym=THB_BTC', ''); + + expect($sigWithoutQuery)->not->toBe($sigWithQuery); +}); + +it('generates different signatures with non-empty payload', function () { + $secret = 'secret'; + $timestamp = 1630483200000; + $method = 'POST'; + $path = '/api/v3/market/place-bid'; + + $sigWithoutPayload = Utility::generateSignature($secret, $timestamp, $method, $path, '', ''); + $sigWithPayload = Utility::generateSignature($secret, $timestamp, $method, $path, '', '{"sym":"THB_BTC","amt":1000}'); + + expect($sigWithoutPayload)->not->toBe($sigWithPayload); +}); + +it('generates different signatures for different methods', function () { + $secret = 'secret'; + $timestamp = 1630483200000; + $path = '/api/market/trades'; + + $sigGet = Utility::generateSignature($secret, $timestamp, 'GET', $path, '', ''); + $sigPost = Utility::generateSignature($secret, $timestamp, 'POST', $path, '', ''); + + expect($sigGet)->not->toBe($sigPost); +}); + +it('generates consistent signatures for same inputs', function () { + $secret = 'secret'; + $timestamp = 1630483200000; + $method = 'POST'; + $path = '/api/v3/market/balances'; + $query = '?sym=THB_BTC'; + $payload = '{"amt":1000}'; + + $sig1 = Utility::generateSignature($secret, $timestamp, $method, $path, $query, $payload); + $sig2 = Utility::generateSignature($secret, $timestamp, $method, $path, $query, $payload); + + expect($sig1)->toBe($sig2); + expect(strlen($sig1))->toBe(64); // SHA-256 hex +}); diff --git a/tests/ClientBuilderTest.php b/tests/ClientBuilderTest.php index 75142f0..11de93a 100644 --- a/tests/ClientBuilderTest.php +++ b/tests/ClientBuilderTest.php @@ -1,5 +1,7 @@ setRetries(-1); })->throws(\InvalidArgumentException::class, 'Retries must be greater than or equal to 0.'); + +it('can set retries to zero', function () { + $client = ClientBuilder::create() + ->setCredentials('test', 'secret') + ->setRetries(0) + ->build(); + + expect($client)->toBeInstanceOf(Client::class); +}); + +it('returns same market endpoint instance (lazy singleton)', function () { + $client = ClientBuilder::create() + ->setCredentials('test', 'secret') + ->build(); + + $first = $client->market(); + $second = $client->market(); + + expect($first)->toBe($second); +}); + +it('returns same crypto endpoint instance (lazy singleton)', function () { + $client = ClientBuilder::create() + ->setCredentials('test', 'secret') + ->build(); + + $first = $client->crypto(); + $second = $client->crypto(); + + expect($first)->toBe($second); +}); + +it('returns same user endpoint instance (lazy singleton)', function () { + $client = ClientBuilder::create() + ->setCredentials('test', 'secret') + ->build(); + + $first = $client->user(); + $second = $client->user(); + + expect($first)->toBe($second); +}); + +it('returns same system endpoint instance (lazy singleton)', function () { + $client = ClientBuilder::create() + ->setCredentials('test', 'secret') + ->build(); + + $first = $client->system(); + $second = $client->system(); + + expect($first)->toBe($second); +}); + +it('can access getTransport', function () { + $client = ClientBuilder::create() + ->setCredentials('test', 'secret') + ->build(); + + expect($client->getTransport())->toBeInstanceOf(\Farzai\Transport\Transport::class); +}); + +it('can access getConfig with correct keys', function () { + $client = ClientBuilder::create() + ->setCredentials('my-api-key', 'my-secret') + ->build(); + + $config = $client->getConfig(); + + expect($config)->toHaveKey('api_key', 'my-api-key'); + expect($config)->toHaveKey('secret', 'my-secret'); +}); + +it('can access getLogger', function () { + $client = ClientBuilder::create() + ->setCredentials('test', 'secret') + ->build(); + + expect($client->getLogger())->toBeInstanceOf(\Psr\Log\LoggerInterface::class); +}); + +it('uses custom logger when set', function () { + $logger = new \Psr\Log\NullLogger; + + $client = ClientBuilder::create() + ->setCredentials('test', 'secret') + ->setLogger($logger) + ->build(); + + expect($client->getLogger())->toBe($logger); +}); diff --git a/tests/ErrorCodesTest.php b/tests/ErrorCodesTest.php index 5ad8396..0b1ad5a 100644 --- a/tests/ErrorCodesTest.php +++ b/tests/ErrorCodesTest.php @@ -1,5 +1,7 @@ toBe('Invalid user'); }); + +it('should return unknown error code message for unrecognized code', function () { + expect(ErrorCodes::getDescription(9999)) + ->toBe('Unknown error code: 9999'); +}); + +it('should return correct descriptions for all known codes', function () { + expect(ErrorCodes::getDescription(ErrorCodes::NO_ERROR))->toBe('No error'); + expect(ErrorCodes::getDescription(ErrorCodes::SERVER_ERROR))->toBe('Server error (please contact support)'); + expect(ErrorCodes::getDescription(ErrorCodes::INSUFFICIENT_BALANCE))->toBe('Insufficient balance'); +}); + +it('all() includes both constant values and the DESCRIPTIONS array', function () { + $all = ErrorCodes::all(); + + expect($all)->toContain(ErrorCodes::NO_ERROR); + expect($all)->toContain(ErrorCodes::INVALID_JSON_PAYLOAD); + expect($all)->toContain(ErrorCodes::SERVER_ERROR); +}); diff --git a/tests/GenerateSignatureV3Test.php b/tests/GenerateSignatureV3Test.php new file mode 100644 index 0000000..f695fff --- /dev/null +++ b/tests/GenerateSignatureV3Test.php @@ -0,0 +1,120 @@ +addSequence(fn ($client) => MockHttpClient::responseServerTimestamp()); + + $client = ClientBuilder::create() + ->setCredentials('my-api-key', 'my-secret') + ->setHttpClient($psrClient) + ->build(); + + $config = $client->getConfig(); + $interceptor = new GenerateSignatureV3($config, $client); + + // Build a simple request + $request = (new \Farzai\Transport\RequestBuilder) + ->method('POST') + ->uri('/api/v3/market/balances') + ->build(); + + $signedRequest = $interceptor->apply($request); + + expect($signedRequest->getHeaderLine('X-BTK-APIKEY'))->toBe('my-api-key'); + expect($signedRequest->getHeaderLine('X-BTK-SIGN'))->not->toBeEmpty(); + expect($signedRequest->getHeaderLine('X-BTK-TIMESTAMP'))->not->toBeEmpty(); +}); + +it('applies signature with query string present', function () { + $psrClient = MockHttpClient::make() + ->addSequence(fn ($client) => MockHttpClient::responseServerTimestamp()); + + $client = ClientBuilder::create() + ->setCredentials('my-api-key', 'my-secret') + ->setHttpClient($psrClient) + ->build(); + + $config = $client->getConfig(); + $interceptor = new GenerateSignatureV3($config, $client); + + // Build a request with query params + $request = (new \Farzai\Transport\RequestBuilder) + ->method('GET') + ->uri('/api/v3/market/my-open-orders') + ->withQuery(['sym' => 'THB_BTC']) + ->build(); + + $signedRequest = $interceptor->apply($request); + + expect($signedRequest->getHeaderLine('X-BTK-APIKEY'))->toBe('my-api-key'); + expect($signedRequest->getHeaderLine('X-BTK-SIGN'))->not->toBeEmpty(); + // Signature should differ from a request without query + $signedRequest2 = $interceptor->apply( + (new \Farzai\Transport\RequestBuilder) + ->method('GET') + ->uri('/api/v3/market/my-open-orders') + ->build() + ); + + expect($signedRequest->getHeaderLine('X-BTK-SIGN')) + ->not->toBe($signedRequest2->getHeaderLine('X-BTK-SIGN')); +}); + +it('applies signature with request body', function () { + $psrClient = MockHttpClient::make() + ->addSequence(fn ($client) => MockHttpClient::responseServerTimestamp()); + + $client = ClientBuilder::create() + ->setCredentials('my-api-key', 'my-secret') + ->setHttpClient($psrClient) + ->build(); + + $config = $client->getConfig(); + $interceptor = new GenerateSignatureV3($config, $client); + + // Build a request with a body + $request = (new \Farzai\Transport\RequestBuilder) + ->method('POST') + ->uri('/api/v3/market/place-bid') + ->withJson(['sym' => 'THB_BTC', 'amt' => 1000, 'rat' => 15000, 'typ' => 'limit']) + ->build(); + + $signedRequest = $interceptor->apply($request); + + expect($signedRequest->getHeaderLine('X-BTK-APIKEY'))->toBe('my-api-key'); + expect($signedRequest->getHeaderLine('X-BTK-SIGN'))->not->toBeEmpty(); + expect($signedRequest->getHeaderLine('X-BTK-TIMESTAMP'))->not->toBeEmpty(); +}); + +it('reuses timestamp within sync interval', function () { + $psrClient = MockHttpClient::make() + ->addSequence(fn ($client) => MockHttpClient::responseServerTimestamp()); + + $client = ClientBuilder::create() + ->setCredentials('my-api-key', 'my-secret') + ->setHttpClient($psrClient) + ->build(); + + $config = $client->getConfig(); + $interceptor = new GenerateSignatureV3($config, $client); + + $request = (new \Farzai\Transport\RequestBuilder) + ->method('POST') + ->uri('/api/v3/market/balances') + ->build(); + + // First call syncs with server + $signed1 = $interceptor->apply($request); + + // Second call should reuse the cached drift (no second HTTP call needed) + $signed2 = $interceptor->apply($request); + + expect($signed1->getHeaderLine('X-BTK-APIKEY'))->toBe('my-api-key'); + expect($signed2->getHeaderLine('X-BTK-APIKEY'))->toBe('my-api-key'); +}); diff --git a/tests/MockHttpClient.php b/tests/MockHttpClient.php index 4fbe779..96d7c42 100644 --- a/tests/MockHttpClient.php +++ b/tests/MockHttpClient.php @@ -1,5 +1,7 @@ + */ + private array $recordedRequests = []; + /** * Create a new mock http client instance. */ @@ -48,13 +55,34 @@ public function addSequence(PsrResponseInterface|callable ...$responses): self */ public function sendRequest(PsrRequestInterface $request): PsrResponseInterface { + $this->recordedRequests[] = $request; + + if (empty($this->sequence)) { + throw new \RuntimeException('No more mock responses in the sequence. Did you forget to add mock responses?'); + } + return array_shift($this->sequence); } + /** + * @return array + */ + public function getRecordedRequests(): array + { + return $this->recordedRequests; + } + + public function getLastRequest(): ?PsrRequestInterface + { + return end($this->recordedRequests) ?: null; + } + public function createStream(string $contents): PsrStreamInterface { $stream = $this->createMock(PsrStreamInterface::class); $stream->method('getContents')->willReturn($contents); + $stream->method('__toString')->willReturn($contents); + $stream->method('rewind')->willReturnCallback(function () {}); return $stream; } diff --git a/tests/PendingRequestTest.php b/tests/PendingRequestTest.php index 06e3ca9..598a798 100644 --- a/tests/PendingRequestTest.php +++ b/tests/PendingRequestTest.php @@ -1,5 +1,7 @@ build(); $request = new PendingRequest($client, 'GET', '/api/market/balances'); - $request->method('POST'); + $result = $request->method('POST'); - expect($request->method)->toBe('POST'); + expect($result)->toBeInstanceOf(PendingRequest::class); }); it('can set request path', function () { @@ -30,9 +32,9 @@ ->build(); $request = new PendingRequest($client, 'GET', '/api/market/balances'); - $request->path('/api/market/orders'); + $result = $request->path('/api/market/orders'); - expect($request->path)->toBe('/api/market/orders'); + expect($result)->toBeInstanceOf(PendingRequest::class); }); it('can set request options', function () { @@ -41,9 +43,9 @@ ->build(); $request = new PendingRequest($client, 'GET', '/api/market/balances'); - $request->options(['query' => ['symbol' => 'BTC']]); + $result = $request->options(['query' => ['symbol' => 'BTC']]); - expect($request->options)->toBe(['query' => ['symbol' => 'BTC']]); + expect($result)->toBeInstanceOf(PendingRequest::class); }); it('can add request interceptor', function () { @@ -51,16 +53,16 @@ ->setCredentials('test', 'secret') ->build(); - $request = $this->createMock(\Psr\Http\Message\RequestInterface::class); + $mockRequest = $this->createMock(\Psr\Http\Message\RequestInterface::class); $pending = new PendingRequest($client, 'GET', '/api/market/balances'); $interceptor = $this->createMock(\Farzai\Bitkub\Contracts\RequestInterceptor::class); - $interceptor->method('apply')->willReturn($request); + $interceptor->method('apply')->willReturn($mockRequest); - $pending->withInterceptor($interceptor); + $result = $pending->withInterceptor($interceptor); - expect($pending->interceptors)->toContain($interceptor); + expect($result)->toBeInstanceOf(PendingRequest::class); }); it('can set request body', function () { @@ -69,9 +71,9 @@ ->build(); $request = new PendingRequest($client, 'POST', '/api/market/orders'); - $request->withBody(['symbol' => 'BTC', 'quantity' => 1]); + $result = $request->withBody(['symbol' => 'BTC', 'quantity' => 1]); - expect($request->options['body'])->toBe(['symbol' => 'BTC', 'quantity' => 1]); + expect($result)->toBeInstanceOf(PendingRequest::class); }); it('can set request query', function () { @@ -80,9 +82,9 @@ ->build(); $request = new PendingRequest($client, 'GET', '/api/market/balances'); - $request->withQuery(['symbol' => 'BTC']); + $result = $request->withQuery(['symbol' => 'BTC']); - expect($request->options['query'])->toBe(['symbol' => 'BTC']); + expect($result)->toBeInstanceOf(PendingRequest::class); }); it('can set request headers', function () { @@ -91,9 +93,9 @@ ->build(); $request = new PendingRequest($client, 'GET', '/api/market/balances'); - $request->withHeaders(['Authorization' => 'Bearer token']); + $result = $request->withHeaders(['Authorization' => 'Bearer token']); - expect($request->options['headers'])->toBe(['Authorization' => 'Bearer token']); + expect($result)->toBeInstanceOf(PendingRequest::class); }); it('can set request header', function () { @@ -102,9 +104,9 @@ ->build(); $request = new PendingRequest($client, 'GET', '/api/market/balances'); - $request->withHeader('Content-Type', 'application/json'); + $result = $request->withHeader('Content-Type', 'application/json'); - expect($request->options['headers']['Content-Type'])->toBe('application/json'); + expect($result)->toBeInstanceOf(PendingRequest::class); }); it('can set request to accept JSON', function () { @@ -113,9 +115,9 @@ ->build(); $request = new PendingRequest($client, 'GET', '/api/market/balances'); - $request->acceptJson(); + $result = $request->acceptJson(); - expect($request->options['headers']['Accept'])->toBe('application/json'); + expect($result)->toBeInstanceOf(PendingRequest::class); }); it('can set request to send JSON', function () { @@ -124,9 +126,9 @@ ->build(); $request = new PendingRequest($client, 'POST', '/api/market/orders'); - $request->asJson(); + $result = $request->asJson(); - expect($request->options['headers']['Content-Type'])->toBe('application/json'); + expect($result)->toBeInstanceOf(PendingRequest::class); }); it('it can createRequest success', function () { @@ -208,3 +210,95 @@ expect($response)->toBeInstanceOf(\Farzai\Bitkub\Responses\ResponseWithValidateErrorCode::class); expect($response->statusCode())->toBe(200); }); + +it('can send request with interceptors applied', function () { + $psrClient = \Farzai\Bitkub\Tests\MockHttpClient::make() + ->addSequence(\Farzai\Bitkub\Tests\MockHttpClient::response(200, json_encode([ + 'error' => 0, + 'result' => 'ok', + ]))); + + $client = ClientBuilder::create() + ->setCredentials('test', 'secret') + ->setHttpClient($psrClient) + ->build(); + + $interceptorCalled = false; + $interceptor = $this->createMock(\Farzai\Bitkub\Contracts\RequestInterceptor::class); + $interceptor->method('apply')->willReturnCallback(function ($request) use (&$interceptorCalled) { + $interceptorCalled = true; + + return $request->withHeader('X-Custom', 'test'); + }); + + $pending = new PendingRequest($client, 'GET', '/api/market/symbols'); + $response = $pending->withInterceptor($interceptor)->send(); + + expect($interceptorCalled)->toBeTrue(); + expect($response)->toBeInstanceOf(\Farzai\Transport\Contracts\ResponseInterface::class); + expect($response->json('result'))->toBe('ok'); +}); + +it('can send request with multiple interceptors applied in order', function () { + $psrClient = \Farzai\Bitkub\Tests\MockHttpClient::make() + ->addSequence(\Farzai\Bitkub\Tests\MockHttpClient::response(200, json_encode([ + 'error' => 0, + ]))); + + $client = ClientBuilder::create() + ->setCredentials('test', 'secret') + ->setHttpClient($psrClient) + ->build(); + + $order = []; + + $interceptor1 = $this->createMock(\Farzai\Bitkub\Contracts\RequestInterceptor::class); + $interceptor1->method('apply')->willReturnCallback(function ($request) use (&$order) { + $order[] = 'first'; + + return $request; + }); + + $interceptor2 = $this->createMock(\Farzai\Bitkub\Contracts\RequestInterceptor::class); + $interceptor2->method('apply')->willReturnCallback(function ($request) use (&$order) { + $order[] = 'second'; + + return $request; + }); + + $pending = new PendingRequest($client, 'GET', '/api/market/symbols'); + $pending->withInterceptor($interceptor1)->withInterceptor($interceptor2)->send(); + + expect($order)->toBe(['first', 'second']); +}); + +it('withHeaders merges with existing headers', function () { + $client = ClientBuilder::create() + ->setCredentials('test', 'secret') + ->build(); + + $pending = new PendingRequest($client, 'GET', '/api/market/balances'); + $pending->withHeaders(['X-First' => 'one']); + $pending->withHeaders(['X-Second' => 'two']); + + $request = $pending->createRequest('GET', '/api/market/balances', [ + 'headers' => ['X-First' => 'one', 'X-Second' => 'two'], + ]); + + expect($request->getHeaderLine('X-First'))->toBe('one'); + expect($request->getHeaderLine('X-Second'))->toBe('two'); +}); + +it('createRequest with empty query array omits query string', function () { + $client = ClientBuilder::create() + ->setCredentials('test', 'secret') + ->build(); + + $pending = new PendingRequest($client, 'GET', '/api/market/ticker'); + + $request = $pending->createRequest('GET', '/api/market/ticker', [ + 'query' => [], + ]); + + expect($request->getUri()->getQuery())->toBe(''); +}); diff --git a/tests/Pest.php b/tests/Pest.php index b3d9bbc..2cb708f 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1 +1,6 @@ in(__DIR__); diff --git a/tests/ResponseErrorCodeTest.php b/tests/ResponseErrorCodeTest.php index 1328ffa..0e7fcdf 100644 --- a/tests/ResponseErrorCodeTest.php +++ b/tests/ResponseErrorCodeTest.php @@ -1,5 +1,7 @@ getResponse())->toBe($psrResponse); }); -it('should be error code not found', function () { +it('should handle missing error code gracefully', function () { $psrResponse = MockHttpClient::response(200, json_encode([ // Empty error code ])); - new BitkubResponseErrorCodeException($psrResponse); -})->throws(\Exception::class, 'Error code not found.'); + $exception = new BitkubResponseErrorCodeException($psrResponse); + + expect($exception->getMessage())->toBe('Malformed response: error code not found.'); + expect($exception->getCode())->toBe(0); +}); it('should not throw exception when error code is null', function () { $psrRequest = $this->createMock(PsrRequestInterface::class); @@ -144,6 +149,38 @@ expect($response->getStatusCode())->toBe(201); }); +it('throw with custom callback still validates error codes', function () { + $psrRequest = $this->createMock(PsrRequestInterface::class); + $psrResponse = MockHttpClient::response(200, json_encode([ + 'error' => 1, + ])); + + $response = new Response($psrRequest, $psrResponse); + $response = new ResponseWithValidateErrorCode($response); + + $callbackCalled = false; + $response->throw(function () use (&$callbackCalled) { + $callbackCalled = true; + }); +})->throws(BitkubResponseErrorCodeException::class, 'Invalid JSON payload'); + +it('throw with custom callback is called when no error', function () { + $psrRequest = $this->createMock(PsrRequestInterface::class); + $psrResponse = MockHttpClient::response(200, json_encode([ + 'error' => 0, + ])); + + $response = new Response($psrRequest, $psrResponse); + $response = new ResponseWithValidateErrorCode($response); + + $callbackCalled = false; + $response->throw(function () use (&$callbackCalled) { + $callbackCalled = true; + }); + + expect($callbackCalled)->toBeTrue(); +}); + it('decorator delegates PSR-7 getBody', function () { $psrRequest = $this->createMock(PsrRequestInterface::class); $psrResponse = MockHttpClient::response(200, json_encode(['error' => 0])); @@ -153,3 +190,77 @@ expect($response->getBody())->toBeInstanceOf(\Psr\Http\Message\StreamInterface::class); }); + +it('decorator delegates getReasonPhrase', function () { + $psrRequest = $this->createMock(PsrRequestInterface::class); + $psrResponse = MockHttpClient::response(200, json_encode(['error' => 0])); + + $response = new Response($psrRequest, $psrResponse); + $response = new ResponseWithValidateErrorCode($response); + + expect($response->getReasonPhrase())->toBeString(); +}); + +it('decorator delegates getProtocolVersion', function () { + $psrRequest = $this->createMock(PsrRequestInterface::class); + $psrResponse = MockHttpClient::response(200, json_encode(['error' => 0])); + + $response = new Response($psrRequest, $psrResponse); + $response = new ResponseWithValidateErrorCode($response); + + expect($response->getProtocolVersion())->toBeString(); +}); + +it('decorator delegates getHeaders via PSR-7', function () { + $psrRequest = $this->createMock(PsrRequestInterface::class); + $psrResponse = MockHttpClient::response(200, json_encode(['error' => 0]), [ + 'Content-Type' => 'application/json', + ]); + + $response = new Response($psrRequest, $psrResponse); + $response = new ResponseWithValidateErrorCode($response); + + expect($response->getHeaders())->toBeArray(); +}); + +it('decorator delegates hasHeader', function () { + $psrRequest = $this->createMock(PsrRequestInterface::class); + $psrResponse = MockHttpClient::response(200, json_encode(['error' => 0])); + + $response = new Response($psrRequest, $psrResponse); + $response = new ResponseWithValidateErrorCode($response); + + // The mock may or may not have headers, but hasHeader should return a bool + expect($response->hasHeader('Content-Type'))->toBeBool(); +}); + +it('decorator delegates getHeader', function () { + $psrRequest = $this->createMock(PsrRequestInterface::class); + $psrResponse = MockHttpClient::response(200, json_encode(['error' => 0])); + + $response = new Response($psrRequest, $psrResponse); + $response = new ResponseWithValidateErrorCode($response); + + expect($response->getHeader('Content-Type'))->toBeArray(); +}); + +it('decorator delegates getHeaderLine', function () { + $psrRequest = $this->createMock(PsrRequestInterface::class); + $psrResponse = MockHttpClient::response(200, json_encode(['error' => 0])); + + $response = new Response($psrRequest, $psrResponse); + $response = new ResponseWithValidateErrorCode($response); + + expect($response->getHeaderLine('Content-Type'))->toBeString(); +}); + +it('exception with unknown error code uses fallback message', function () { + $psrResponse = MockHttpClient::response(200, json_encode([ + 'error' => 12345, + ])); + + $exception = new BitkubResponseErrorCodeException($psrResponse); + + expect($exception->getCode())->toBe(12345); + expect($exception->getMessage())->toBe('Unknown error code: 12345'); +}); diff --git a/tests/RestApiTest/AbstractEndpointTest.php b/tests/RestApiTest/AbstractEndpointTest.php new file mode 100644 index 0000000..df48b34 --- /dev/null +++ b/tests/RestApiTest/AbstractEndpointTest.php @@ -0,0 +1,90 @@ +setCredentials('test', 'secret') + ->build(); + + $endpoint = new MarketEndpoint($client); + + // Use reflection to test the protected filterParams method + $reflection = new ReflectionMethod($endpoint, 'filterParams'); + + $result = $reflection->invoke($endpoint, [ + 'sym' => 'THB_BTC', + 'lmt' => null, + 'page' => 1, + ]); + + expect($result)->toBe([ + 'sym' => 'THB_BTC', + 'page' => 1, + ]); +}); + +it('filterParams strips empty string values', function () { + $client = ClientBuilder::create() + ->setCredentials('test', 'secret') + ->build(); + + $endpoint = new MarketEndpoint($client); + + $reflection = new ReflectionMethod($endpoint, 'filterParams'); + + $result = $reflection->invoke($endpoint, [ + 'sym' => 'THB_BTC', + 'lmt' => '', + 'page' => 0, + ]); + + expect($result)->toBe([ + 'sym' => 'THB_BTC', + 'page' => 0, + ]); +}); + +it('filterParams keeps zero and false values', function () { + $client = ClientBuilder::create() + ->setCredentials('test', 'secret') + ->build(); + + $endpoint = new MarketEndpoint($client); + + $reflection = new ReflectionMethod($endpoint, 'filterParams'); + + $result = $reflection->invoke($endpoint, [ + 'sym' => 'THB_BTC', + 'lmt' => 0, + 'active' => false, + 'empty' => null, + 'blank' => '', + ]); + + expect($result)->toHaveKey('sym'); + expect($result)->toHaveKey('lmt'); + expect($result)->toHaveKey('active'); + expect($result)->not->toHaveKey('empty'); + expect($result)->not->toHaveKey('blank'); +}); + +it('filterParams returns empty array from all-null input', function () { + $client = ClientBuilder::create() + ->setCredentials('test', 'secret') + ->build(); + + $endpoint = new MarketEndpoint($client); + + $reflection = new ReflectionMethod($endpoint, 'filterParams'); + + $result = $reflection->invoke($endpoint, [ + 'a' => null, + 'b' => '', + ]); + + expect($result)->toBe([]); +}); diff --git a/tests/RestApiTest/CryptoEndpointTest.php b/tests/RestApiTest/CryptoEndpointTest.php index 1db06e3..84a5686 100644 --- a/tests/RestApiTest/CryptoEndpointTest.php +++ b/tests/RestApiTest/CryptoEndpointTest.php @@ -1,5 +1,7 @@ json('result.hash'))->toBe('fwQ6dnQWQPs4cbatF5Am2xCDP1J'); }); +it('should get openOrders success', function () { + $psrClient = MockHttpClient::make() + ->addSequence(fn ($client) => MockHttpClient::responseServerTimestamp()) + ->addSequence(fn ($client) => $client->createResponse(200, json_encode([ + 'error' => 0, + 'result' => [ + [ + 'id' => '2', + 'hash' => 'fwQ6dnQWQPs4cbatFSJpMCcKTFR', + 'side' => 'SELL', + 'type' => 'limit', + 'rate' => 15000, + 'fee' => 35.01, + 'credit' => 35.01, + 'amount' => 0.93333334, + 'receive' => 14000, + 'parent_id' => 1, + 'super_id' => 1, + 'ts' => 1533834844, + ], + ], + ]))); + + $client = ClientBuilder::create() + ->setCredentials('test', 'secret') + ->setHttpClient($psrClient) + ->build(); + + $market = $client->market(); + + $response = $market->openOrders('THB_BTC'); + + expect($response)->toBeInstanceOf(ResponseInterface::class); + expect($response->json('result.0.id'))->toBe('2'); + expect($response->json('result.0.side'))->toBe('SELL'); +}); + +it('should get myOrderHistory success', function () { + $psrClient = MockHttpClient::make() + ->addSequence(fn ($client) => MockHttpClient::responseServerTimestamp()) + ->addSequence(fn ($client) => $client->createResponse(200, json_encode([ + 'error' => 0, + 'result' => [ + [ + 'txn_id' => 'ETHBUY0000000197', + 'order_id' => '240', + 'hash' => 'fwQ6dnQWQPs4cbaujNyejinS43a', + 'side' => 'buy', + 'type' => 'limit', + 'rate' => '13335.57', + 'fee' => '0.34', + 'credit' => '0.34', + 'amount' => '0.00999987', + 'ts' => 1531513395, + ], + ], + 'pagination' => [ + 'page' => 1, + 'last' => 1, + ], + ]))); + + $client = ClientBuilder::create() + ->setCredentials('test', 'secret') + ->setHttpClient($psrClient) + ->build(); + + $market = $client->market(); + + $response = $market->myOrderHistory([ + 'sym' => 'THB_ETH', + 'p' => 1, + 'lmt' => 10, + ]); + + expect($response)->toBeInstanceOf(ResponseInterface::class); + expect($response->json('result.0.txn_id'))->toBe('ETHBUY0000000197'); +}); + +it('should get myOrderInfo success', function () { + $psrClient = MockHttpClient::make() + ->addSequence(fn ($client) => MockHttpClient::responseServerTimestamp()) + ->addSequence(fn ($client) => $client->createResponse(200, json_encode([ + 'error' => 0, + 'result' => [ + 'id' => '289', + 'first' => '289', + 'parent' => '0', + 'last' => '316', + 'amount' => 4000, + 'rate' => 291000, + 'fee' => 10, + 'credit' => 10, + 'filled' => 3999.97, + 'total' => 4000, + 'status' => 'filled', + ], + ]))); + + $client = ClientBuilder::create() + ->setCredentials('test', 'secret') + ->setHttpClient($psrClient) + ->build(); + + $market = $client->market(); + + $response = $market->myOrderInfo([ + 'sym' => 'THB_BTC', + 'id' => '289', + 'sd' => 'buy', + ]); + + expect($response)->toBeInstanceOf(ResponseInterface::class); + expect($response->json('result.id'))->toBe('289'); + expect($response->json('result.status'))->toBe('filled'); +}); + +it('should get ticker with symbol param success', function () { + $psrClient = MockHttpClient::make() + ->addSequence(fn ($client) => $client->createResponse(200, json_encode([ + 'THB_BTC' => [ + 'id' => 1, + 'last' => 216415.00, + 'lowestAsk' => 216678.00, + 'highestBid' => 215000.00, + 'percentChange' => 1.91, + 'baseVolume' => 71.02603946, + 'quoteVolume' => 15302897.99, + 'isFrozen' => 0, + 'high24hr' => 221396.00, + 'low24hr' => 206414.00, + ], + ]))); + + $client = ClientBuilder::create() + ->setCredentials('test', 'secret') + ->setHttpClient($psrClient) + ->build(); + + $market = $client->market(); + + $response = $market->ticker('THB_BTC'); + + expect($response)->toBeInstanceOf(ResponseInterface::class); + expect($response->json('THB_BTC.id'))->toBe(1); +}); + +it('should handle trades with null params without error', function () { + $psrClient = MockHttpClient::make() + ->addSequence(fn ($client) => $client->createResponse(200, json_encode([ + 'error' => 0, + 'result' => [], + ]))); + + $client = ClientBuilder::create() + ->setCredentials('test', 'secret') + ->setHttpClient($psrClient) + ->build(); + + $market = $client->market(); + + $response = $market->trades([ + 'sym' => 'THB_BTC', + 'lmt' => null, + ]); + + expect($response)->toBeInstanceOf(ResponseInterface::class); + expect($response->json('error'))->toBe(0); +}); + +it('should handle trades with empty string params without error', function () { + $psrClient = MockHttpClient::make() + ->addSequence(fn ($client) => $client->createResponse(200, json_encode([ + 'error' => 0, + 'result' => [], + ]))); + + $client = ClientBuilder::create() + ->setCredentials('test', 'secret') + ->setHttpClient($psrClient) + ->build(); + + $market = $client->market(); + + $response = $market->trades([ + 'sym' => 'THB_BTC', + 'lmt' => '', + ]); + + expect($response)->toBeInstanceOf(ResponseInterface::class); + expect($response->json('error'))->toBe(0); +}); + it('should call cancelOrder success', function () { $psrClient = MockHttpClient::make() ->addSequence(fn ($client) => MockHttpClient::responseServerTimestamp()) diff --git a/tests/RestApiTest/SystemEndpointTest.php b/tests/RestApiTest/SystemEndpointTest.php index 695ed50..da216aa 100644 --- a/tests/RestApiTest/SystemEndpointTest.php +++ b/tests/RestApiTest/SystemEndpointTest.php @@ -1,5 +1,7 @@ getHost())->toBe('api.bitkub.com'); expect($uri->getPath())->toBe(''); }); + +it('can create uri with path, query, and port', function () { + $uri = UriFactory::createFromUri('https://api.bitkub.com:8443/api/v3/market?sym=THB_BTC'); + + expect($uri->getScheme())->toBe('https'); + expect($uri->getHost())->toBe('api.bitkub.com'); + expect($uri->getPort())->toBe(8443); + expect($uri->getPath())->toBe('/api/v3/market'); + expect($uri->getQuery())->toBe('sym=THB_BTC'); +}); + +it('can create uri with fragment', function () { + $uri = UriFactory::createFromUri('https://api.bitkub.com/docs#section'); + + expect($uri->getPath())->toBe('/docs'); + expect($uri->getFragment())->toBe('section'); +}); + +it('can create uri with http scheme', function () { + $uri = UriFactory::createFromUri('http://localhost:3000/api'); + + expect($uri->getScheme())->toBe('http'); + expect($uri->getHost())->toBe('localhost'); + expect($uri->getPort())->toBe(3000); +}); diff --git a/tests/WebSocketTests/EngineTest.php b/tests/WebSocketTests/EngineTest.php new file mode 100644 index 0000000..f8c9860 --- /dev/null +++ b/tests/WebSocketTests/EngineTest.php @@ -0,0 +1,83 @@ +addMessage('market.trade.thb_btc', ['price' => 100]); + + $received = []; + $listeners = [ + 'market.trade.thb_btc' => [ + function (Message $m) use (&$received) { + $received[] = $m->json('price'); + }, + ], + ]; + + $engine->handle($listeners); + + expect($received)->toBe([100]); +}); + +it('dispatches to multiple listeners for same stream', function () { + $engine = new MockWebSocketEngine; + $engine->addMessage('market.trade.thb_btc', ['price' => 200]); + + $calls = 0; + $listeners = [ + 'market.trade.thb_btc' => [ + function (Message $m) use (&$calls) { + $calls++; + }, + function (Message $m) use (&$calls) { + $calls++; + }, + ], + ]; + + $engine->handle($listeners); + + expect($calls)->toBe(2); +}); + +it('ignores messages for unregistered streams', function () { + $engine = new MockWebSocketEngine; + $engine->addMessage('market.trade.thb_eth', ['price' => 300]); + + $received = []; + $listeners = [ + 'market.trade.thb_btc' => [ + function (Message $m) use (&$received) { + $received[] = $m->json('price'); + }, + ], + ]; + + $engine->handle($listeners); + + expect($received)->toBe([]); +}); + +it('dispatches messages with pre-decoded data', function () { + $engine = new MockWebSocketEngine; + $engine->addMessage('market.trade.thb_btc', ['price' => 500, 'vol' => 1.5]); + + $received = null; + $listeners = [ + 'market.trade.thb_btc' => [ + function (Message $m) use (&$received) { + $received = $m->json(); + }, + ], + ]; + + $engine->handle($listeners); + + expect($received)->toHaveKey('price', 500); + expect($received)->toHaveKey('vol', 1.5); + expect($received)->toHaveKey('stream', 'market.trade.thb_btc'); +}); diff --git a/tests/WebSocketTests/LiveOrderBookEndpointTest.php b/tests/WebSocketTests/LiveOrderBookEndpointTest.php new file mode 100644 index 0000000..5b871e2 --- /dev/null +++ b/tests/WebSocketTests/LiveOrderBookEndpointTest.php @@ -0,0 +1,152 @@ +listen('thb_btc', function () {}); +})->throws(\RuntimeException::class, 'A REST client is required to resolve symbol names.'); + +it('accepts numeric symbol ID without REST client', function () { + $engine = new MockWebSocketEngine; + $client = new WebSocketClient($engine); + $endpoint = new LiveOrderBookEndpoint($client); + + $endpoint->listen(1, function () {}); + + expect($client->getListeners())->toHaveKey('orderbook/1'); +}); + +it('caches symbol map per instance not globally', function () { + $symbolResponseBody = [ + 'error' => 0, + 'result' => [ + ['id' => 1, 'symbol' => 'THB_BTC', 'info' => 'Thai Baht to Bitcoin'], + ['id' => 2, 'symbol' => 'THB_ETH', 'info' => 'Thai Baht to Ethereum'], + ], + ]; + + // First instance + $httpClient1 = MockHttpClient::make() + ->addSequence(MockHttpClient::response(200, json_encode($symbolResponseBody))); + + $baseClient1 = ClientBuilder::create() + ->setCredentials('key', 'secret') + ->setHttpClient($httpClient1) + ->build(); + + $engine1 = new MockWebSocketEngine; + $wsClient1 = new WebSocketClient($engine1, $baseClient1); + $endpoint1 = new LiveOrderBookEndpoint($wsClient1); + $endpoint1->listen('thb_btc', function () {}); + + // Second instance — should make its own HTTP call (not use static cache) + $httpClient2 = MockHttpClient::make() + ->addSequence(MockHttpClient::response(200, json_encode($symbolResponseBody))); + + $baseClient2 = ClientBuilder::create() + ->setCredentials('key', 'secret') + ->setHttpClient($httpClient2) + ->build(); + + $engine2 = new MockWebSocketEngine; + $wsClient2 = new WebSocketClient($engine2, $baseClient2); + $endpoint2 = new LiveOrderBookEndpoint($wsClient2); + $endpoint2->listen('thb_btc', function () {}); + + // Both should have their own listeners + expect($wsClient1->getListeners())->toHaveKey('orderbook/1'); + expect($wsClient2->getListeners())->toHaveKey('orderbook/1'); +}); + +it('throws for unknown symbol name', function () { + $symbolResponseBody = [ + 'error' => 0, + 'result' => [ + ['id' => 1, 'symbol' => 'THB_BTC', 'info' => 'Thai Baht to Bitcoin'], + ], + ]; + + $httpClient = MockHttpClient::make() + ->addSequence(MockHttpClient::response(200, json_encode($symbolResponseBody))); + + $baseClient = ClientBuilder::create() + ->setCredentials('key', 'secret') + ->setHttpClient($httpClient) + ->build(); + + $engine = new MockWebSocketEngine; + $client = new WebSocketClient($engine, $baseClient); + $endpoint = new LiveOrderBookEndpoint($client); + + $endpoint->listen('thb_unknown', function () {}); +})->throws(\InvalidArgumentException::class, 'Invalid symbol name. Given: thb_unknown'); + +it('listen returns static for chaining', function () { + $engine = new MockWebSocketEngine; + $client = new WebSocketClient($engine); + $endpoint = new LiveOrderBookEndpoint($client); + + $result = $endpoint->listen(1, function () {}); + + expect($result)->toBe($endpoint); +}); + +it('accepts numeric string as symbol ID', function () { + $engine = new MockWebSocketEngine; + $client = new WebSocketClient($engine); + $endpoint = new LiveOrderBookEndpoint($client); + + $endpoint->listen('42', function () {}); + + expect($client->getListeners())->toHaveKey('orderbook/42'); +}); + +it('resolves symbol names case-insensitively', function () { + $symbolResponseBody = [ + 'error' => 0, + 'result' => [ + ['id' => 1, 'symbol' => 'THB_BTC', 'info' => 'Thai Baht to Bitcoin'], + ], + ]; + + $httpClient = MockHttpClient::make() + ->addSequence(MockHttpClient::response(200, json_encode($symbolResponseBody))); + + $baseClient = ClientBuilder::create() + ->setCredentials('key', 'secret') + ->setHttpClient($httpClient) + ->build(); + + $engine = new MockWebSocketEngine; + $wsClient = new WebSocketClient($engine, $baseClient); + $endpoint = new LiveOrderBookEndpoint($wsClient); + + // Mixed case should still resolve + $endpoint->listen('Thb_Btc', function () {}); + + expect($wsClient->getListeners())->toHaveKey('orderbook/1'); +}); + +it('supports array of listeners', function () { + $engine = new MockWebSocketEngine; + $client = new WebSocketClient($engine); + $endpoint = new LiveOrderBookEndpoint($client); + + $endpoint->listen(1, [ + function () {}, + function () {}, + ]); + + expect($client->getListeners())->toHaveKey('orderbook/1'); + expect($client->getListeners()['orderbook/1'])->toHaveCount(2); +}); diff --git a/tests/WebSocketTests/MarketEndpointTest.php b/tests/WebSocketTests/MarketEndpointTest.php new file mode 100644 index 0000000..6719571 --- /dev/null +++ b/tests/WebSocketTests/MarketEndpointTest.php @@ -0,0 +1,92 @@ +listen('invalid!!!', function () {}); +})->throws(\InvalidArgumentException::class, 'Invalid stream name format.'); + +it('normalizes stream with and without market. prefix', function () { + $engine = new MockWebSocketEngine; + $client = new WebSocketClient($engine); + $endpoint = new MarketEndpoint($client); + + $endpoint->listen('trade.thb_btc', function () {}); + $endpoint->listen('market.trade.thb_btc', function () {}); + + $listeners = $client->getListeners(); + expect($listeners)->toHaveCount(1); + expect($listeners['market.trade.thb_btc'])->toHaveCount(2); +}); + +it('listen returns static for chaining', function () { + $engine = new MockWebSocketEngine; + $client = new WebSocketClient($engine); + $endpoint = new MarketEndpoint($client); + + $result = $endpoint->listen('trade.thb_btc', function () {}); + + expect($result)->toBe($endpoint); +}); + +it('supports comma-separated stream names as string', function () { + $engine = new MockWebSocketEngine; + $client = new WebSocketClient($engine); + $endpoint = new MarketEndpoint($client); + + $endpoint->listen('trade.thb_btc, trade.thb_eth', function () {}); + + $listeners = $client->getListeners(); + expect($listeners)->toHaveCount(2); + expect($listeners)->toHaveKey('market.trade.thb_btc'); + expect($listeners)->toHaveKey('market.trade.thb_eth'); +}); + +it('filters empty entries from comma-separated string', function () { + $engine = new MockWebSocketEngine; + $client = new WebSocketClient($engine); + $endpoint = new MarketEndpoint($client); + + $endpoint->listen('trade.thb_btc,,, trade.thb_eth,', function () {}); + + $listeners = $client->getListeners(); + expect($listeners)->toHaveCount(2); + expect($listeners)->toHaveKey('market.trade.thb_btc'); + expect($listeners)->toHaveKey('market.trade.thb_eth'); +}); + +it('supports array of listeners', function () { + $engine = new MockWebSocketEngine; + $client = new WebSocketClient($engine); + $endpoint = new MarketEndpoint($client); + + $endpoint->listen('trade.thb_btc', [ + function () {}, + function () {}, + ]); + + $listeners = $client->getListeners(); + expect($listeners)->toHaveCount(1); + expect($listeners['market.trade.thb_btc'])->toHaveCount(2); +}); + +it('handles stream names with whitespace trimming', function () { + $engine = new MockWebSocketEngine; + $client = new WebSocketClient($engine); + $endpoint = new MarketEndpoint($client); + + $endpoint->listen(' trade.thb_btc , trade.thb_eth ', function () {}); + + $listeners = $client->getListeners(); + expect($listeners)->toHaveCount(2); + expect($listeners)->toHaveKey('market.trade.thb_btc'); + expect($listeners)->toHaveKey('market.trade.thb_eth'); +}); diff --git a/tests/WebSocketTests/MessageObjectTest.php b/tests/WebSocketTests/MessageObjectTest.php index 8ea6a1c..d0bbff2 100644 --- a/tests/WebSocketTests/MessageObjectTest.php +++ b/tests/WebSocketTests/MessageObjectTest.php @@ -1,5 +1,7 @@ json('data.0.0'))->toBe(121.82); - // Test setter - $message->event = 'askschanged'; - expect($message->event)->toBe('askschanged'); + expect(isset($message->event))->toBeTrue(); +}); - $message['event'] = 'askschanged'; - expect($message->event)->toBe('askschanged'); +it('should return null if json is not valid', function () { + $body = 'invalid json'; - // Test unset - expect(isset($message->event))->toBeTrue(); + $currentDateTime = Carbon::now(); - unset($message->event); - expect($message->event)->toBeNull(); - expect(isset($message->event))->toBeFalse(); + $message = new Message($body, $currentDateTime->toDateTimeImmutable()); - unset($message['pairing_id']); - expect($message->pairing_id)->toBeNull(); + expect($message->json())->toBeNull(); }); -it('should return null if json is not valid', function () { +it('should return empty array from toArray when json is invalid', function () { $body = 'invalid json'; $currentDateTime = Carbon::now(); $message = new Message($body, $currentDateTime->toDateTimeImmutable()); - expect($message->json())->toBeNull(); + expect($message->toArray())->toBe([]); +}); + +it('should throw BadMethodCallException on offsetSet', function () { + $body = json_encode(['event' => 'test']); + $message = new Message($body, Carbon::now()->toDateTimeImmutable()); + + $message['event'] = 'changed'; +})->throws(\BadMethodCallException::class, 'Message is immutable.'); + +it('should throw BadMethodCallException on offsetUnset', function () { + $body = json_encode(['event' => 'test']); + $message = new Message($body, Carbon::now()->toDateTimeImmutable()); + + unset($message['event']); +})->throws(\BadMethodCallException::class, 'Message is immutable.'); + +it('should throw BadMethodCallException on __set', function () { + $body = json_encode(['event' => 'test']); + $message = new Message($body, Carbon::now()->toDateTimeImmutable()); + + $message->event = 'changed'; +})->throws(\BadMethodCallException::class, 'Message is immutable.'); + +it('should throw BadMethodCallException on __unset', function () { + $body = json_encode(['event' => 'test']); + $message = new Message($body, Carbon::now()->toDateTimeImmutable()); + + unset($message->event); +})->throws(\BadMethodCallException::class, 'Message is immutable.'); + +it('accepts pre-decoded array and skips json_decode', function () { + $decoded = ['event' => 'trade', 'price' => 42000]; + $body = json_encode($decoded); + + $message = new Message($body, Carbon::now()->toDateTimeImmutable(), $decoded); + + expect($message->json())->toBe($decoded); + expect($message->json('event'))->toBe('trade'); + expect($message->json('price'))->toBe(42000); + expect($message->getBody())->toBe($body); +}); + +it('offsetGet returns null for missing key', function () { + $body = json_encode(['event' => 'test']); + $message = new Message($body, Carbon::now()->toDateTimeImmutable()); + + expect($message['nonexistent'])->toBeNull(); +}); + +it('offsetExists returns false for missing key', function () { + $body = json_encode(['event' => 'test']); + $message = new Message($body, Carbon::now()->toDateTimeImmutable()); + + expect($message->offsetExists('nonexistent'))->toBeFalse(); +}); + +it('__get returns null for missing key', function () { + $body = json_encode(['event' => 'test']); + $message = new Message($body, Carbon::now()->toDateTimeImmutable()); + + expect($message->nonexistent)->toBeNull(); +}); + +it('__isset returns false for missing key', function () { + $body = json_encode(['event' => 'test']); + $message = new Message($body, Carbon::now()->toDateTimeImmutable()); + + expect(isset($message->nonexistent))->toBeFalse(); +}); + +it('json with key returns null on invalid json body', function () { + $body = 'not json'; + $message = new Message($body, Carbon::now()->toDateTimeImmutable()); + + expect($message->json('somekey'))->toBeNull(); +}); + +it('jsonSerialize returns same as toArray', function () { + $decoded = ['event' => 'trade', 'data' => [1, 2, 3]]; + $body = json_encode($decoded); + $message = new Message($body, Carbon::now()->toDateTimeImmutable()); + + expect($message->jsonSerialize())->toBe($message->toArray()); }); diff --git a/tests/WebSocketTests/MockWebSocketEngine.php b/tests/WebSocketTests/MockWebSocketEngine.php new file mode 100644 index 0000000..1bbc708 --- /dev/null +++ b/tests/WebSocketTests/MockWebSocketEngine.php @@ -0,0 +1,52 @@ +}> */ + private array $messages = []; + + private bool $handled = false; + + /** + * @param array $data + */ + public function addMessage(string $stream, array $data): self + { + $this->messages[] = ['stream' => $stream, 'data' => $data]; + + return $this; + } + + public function handle(array $listeners): void + { + $this->handled = true; + + foreach ($this->messages as $queued) { + $stream = $queued['stream']; + if (! isset($listeners[$stream])) { + continue; + } + + $payload = array_merge($queued['data'], ['stream' => $stream]); + $body = json_encode($payload); + $message = new Message($body, Carbon::now()->toDateTimeImmutable(), $payload); + + foreach ($listeners[$stream] as $listener) { + $listener($message); + } + } + } + + public function wasHandled(): bool + { + return $this->handled; + } +} diff --git a/tests/WebSocketTests/WebSocketClientBuilderTest.php b/tests/WebSocketTests/WebSocketClientBuilderTest.php new file mode 100644 index 0000000..c422398 --- /dev/null +++ b/tests/WebSocketTests/WebSocketClientBuilderTest.php @@ -0,0 +1,118 @@ +setCredentials('key', 'secret') + ->build(); + + $ws = WebSocketClientBuilder::create() + ->setClient($baseClient) + ->build(); + + expect($ws)->toBeInstanceOf(WebSocketClient::class); + expect($ws->getClient())->toBe($baseClient); +}); + +it('uses NullLogger when no logger set', function () { + $ws = WebSocketClientBuilder::create() + ->build(); + + expect($ws->getLogger())->toBeInstanceOf(\Psr\Log\NullLogger::class); +}); + +it('can set custom base URL', function () { + $ws = WebSocketClientBuilder::create() + ->setBaseUrl('wss://custom.example.com/ws/') + ->build(); + + expect($ws)->toBeInstanceOf(WebSocketClient::class); +}); + +it('can set custom engine for testing', function () { + $engine = new MockWebSocketEngine; + + $ws = WebSocketClientBuilder::create() + ->setEngine($engine) + ->build(); + + expect($ws)->toBeInstanceOf(WebSocketClient::class); + + $ws->run(); + + expect($engine->wasHandled())->toBeTrue(); +}); + +it('throws when reconnect attempts is negative', function () { + WebSocketClientBuilder::create() + ->setReconnectAttempts(-1); +})->throws(\InvalidArgumentException::class, 'Reconnect attempts must be greater than or equal to 0.'); + +it('throws when reconnect delay is negative', function () { + WebSocketClientBuilder::create() + ->setReconnectDelayMs(-1); +})->throws(\InvalidArgumentException::class, 'Reconnect delay must be greater than or equal to 0.'); + +it('can set custom logger', function () { + $logger = new \Psr\Log\NullLogger; + + $ws = WebSocketClientBuilder::create() + ->setLogger($logger) + ->build(); + + expect($ws->getLogger())->toBe($logger); +}); + +it('uses client logger when no explicit logger set', function () { + $baseClient = ClientBuilder::create() + ->setCredentials('key', 'secret') + ->build(); + + $ws = WebSocketClientBuilder::create() + ->setClient($baseClient) + ->build(); + + expect($ws->getLogger())->toBe($baseClient->getLogger()); +}); + +it('allows zero reconnect attempts', function () { + $ws = WebSocketClientBuilder::create() + ->setReconnectAttempts(0) + ->build(); + + expect($ws)->toBeInstanceOf(WebSocketClient::class); +}); + +it('allows zero reconnect delay', function () { + $ws = WebSocketClientBuilder::create() + ->setReconnectDelayMs(0) + ->build(); + + expect($ws)->toBeInstanceOf(WebSocketClient::class); +}); + +it('can set reconnect attempts and delay with custom values', function () { + $engine = new MockWebSocketEngine; + + $ws = WebSocketClientBuilder::create() + ->setEngine($engine) + ->setReconnectAttempts(5) + ->setReconnectDelayMs(2000) + ->build(); + + expect($ws)->toBeInstanceOf(WebSocketClient::class); +}); + +it('builder methods return static for chaining', function () { + $builder = WebSocketClientBuilder::create(); + + expect($builder->setBaseUrl('wss://example.com/'))->toBe($builder); + expect($builder->setReconnectAttempts(3))->toBe($builder); + expect($builder->setReconnectDelayMs(1000))->toBe($builder); +}); diff --git a/tests/WebSocketTests/WebSocketClientTest.php b/tests/WebSocketTests/WebSocketClientTest.php index 38d4191..24ef5e8 100644 --- a/tests/WebSocketTests/WebSocketClientTest.php +++ b/tests/WebSocketTests/WebSocketClientTest.php @@ -1,31 +1,32 @@ setCredentials('YOUR_API_KEY', 'YOUR_SECRET') - ->build() - ); +it('should create new instance of WebSocketClient via builder', function () { + $baseClient = ClientBuilder::create() + ->setCredentials('YOUR_API_KEY', 'YOUR_SECRET') + ->build(); - expect($client)->toBeInstanceOf(WebSocketClient::class); + $ws = WebSocketClientBuilder::create() + ->setClient($baseClient) + ->build(); - expect($client->getConfig())->toBe($baseClient->getConfig()); - expect($client->getLogger())->toBe($baseClient->getLogger()); + expect($ws)->toBeInstanceOf(WebSocketClient::class); + expect($ws->getConfig())->toBe($baseClient->getConfig()); }); it('should add listener success', function () { - $client = new WebSocketClient( - ClientBuilder::create() - ->setCredentials('YOUR_API_KEY', 'YOUR_SECRET') - ->build() - ); + $engine = new MockWebSocketEngine; + $client = new WebSocketClient($engine); $client->addListener('market.trade.thb_btc', function () { // @@ -43,11 +44,8 @@ }); it('should add listener with array', function () { - $client = new WebSocketClient( - ClientBuilder::create() - ->setCredentials('YOUR_API_KEY', 'YOUR_SECRET') - ->build() - ); + $engine = new MockWebSocketEngine; + $client = new WebSocketClient($engine); $client->addListener('market.trade.thb_btc', [ function () { @@ -75,36 +73,30 @@ function () { }); it('should call run on endpoint success', function () { - $client = $this->createMock(WebSocketClient::class); - $client->expects($this->once())->method('run'); + $engine = new MockWebSocketEngine; + $client = new WebSocketClient($engine); $endpoint = new MarketEndpoint($client); expect($endpoint)->toBeInstanceOf(AbstractEndpoint::class); $endpoint->run(); + + expect($engine->wasHandled())->toBeTrue(); }); -it('should call handle on client success', function () { +it('should call handle on engine when run is invoked', function () { $engine = $this->createMock(\Farzai\Bitkub\Contracts\WebSocketEngineInterface::class); $engine->expects($this->once())->method('handle'); - $client = new WebSocketClient( - ClientBuilder::create() - ->setCredentials('YOUR_API_KEY', 'YOUR_SECRET') - ->build(), - $engine, - ); + $client = new WebSocketClient($engine); $client->run(); }); it('should create new instance of market endpoint', function () { - $client = new WebSocketClient( - ClientBuilder::create() - ->setCredentials('YOUR_API_KEY', 'YOUR_SECRET') - ->build() - ); + $engine = new MockWebSocketEngine; + $client = new WebSocketClient($engine); $endpoint = new MarketEndpoint($client); @@ -112,11 +104,8 @@ function () { }); it('can put stream name as string success', function () { - $client = new WebSocketClient( - ClientBuilder::create() - ->setCredentials('YOUR_API_KEY', 'YOUR_SECRET') - ->build() - ); + $engine = new MockWebSocketEngine; + $client = new WebSocketClient($engine); $endpoint = new MarketEndpoint($client); @@ -136,11 +125,8 @@ function () { }); it('can put stream name with array success', function () { - $client = new WebSocketClient( - ClientBuilder::create() - ->setCredentials('YOUR_API_KEY', 'YOUR_SECRET') - ->build() - ); + $engine = new MockWebSocketEngine; + $client = new WebSocketClient($engine); $endpoint = new MarketEndpoint($client); @@ -168,11 +154,8 @@ function () { }); it('should create live order book endpoint success', function () { - $client = new WebSocketClient( - ClientBuilder::create() - ->setCredentials('YOUR_API_KEY', 'YOUR_SECRET') - ->build() - ); + $engine = new MockWebSocketEngine; + $client = new WebSocketClient($engine); $endpoint = new LiveOrderBookEndpoint($client); @@ -180,11 +163,8 @@ function () { }); it('can put symbol by id success', function () { - $client = new WebSocketClient( - ClientBuilder::create() - ->setCredentials('YOUR_API_KEY', 'YOUR_SECRET') - ->build() - ); + $engine = new MockWebSocketEngine; + $client = new WebSocketClient($engine); $endpoint = new LiveOrderBookEndpoint($client); @@ -225,12 +205,13 @@ function () { ->addSequence(MockHttpClient::response(200, json_encode($symbolResponseBody))) ->addSequence(MockHttpClient::response(200, json_encode($symbolResponseBody))); - $client = new WebSocketClient( - ClientBuilder::create() - ->setCredentials('YOUR_API_KEY', 'YOUR_SECRET') - ->setHttpClient($httpClient) - ->build() - ); + $baseClient = ClientBuilder::create() + ->setCredentials('YOUR_API_KEY', 'YOUR_SECRET') + ->setHttpClient($httpClient) + ->build(); + + $engine = new MockWebSocketEngine; + $client = new WebSocketClient($engine, $baseClient); $endpoint = new LiveOrderBookEndpoint($client); @@ -277,12 +258,13 @@ function () { $httpClient = MockHttpClient::make() ->addSequence(MockHttpClient::response(200, json_encode($symbolResponseBody))); - $client = new WebSocketClient( - ClientBuilder::create() - ->setCredentials('YOUR_API_KEY', 'YOUR_SECRET') - ->setHttpClient($httpClient) - ->build() - ); + $baseClient = ClientBuilder::create() + ->setCredentials('YOUR_API_KEY', 'YOUR_SECRET') + ->setHttpClient($httpClient) + ->build(); + + $engine = new MockWebSocketEngine; + $client = new WebSocketClient($engine, $baseClient); $endpoint = new LiveOrderBookEndpoint($client); @@ -290,3 +272,72 @@ function () { // }); })->throws(\InvalidArgumentException::class, 'Invalid symbol name. Given: thb_xxx'); + +it('returns same market endpoint instance (lazy singleton)', function () { + $engine = new MockWebSocketEngine; + $client = new WebSocketClient($engine); + + $first = $client->market(); + $second = $client->market(); + + expect($first)->toBe($second); +}); + +it('returns same liveOrderBook endpoint instance (lazy singleton)', function () { + $engine = new MockWebSocketEngine; + $client = new WebSocketClient($engine); + + $first = $client->liveOrderBook(); + $second = $client->liveOrderBook(); + + expect($first)->toBe($second); +}); + +it('addListener returns static for chaining', function () { + $engine = new MockWebSocketEngine; + $client = new WebSocketClient($engine); + + $result = $client->addListener('test', function () {}); + + expect($result)->toBe($client); +}); + +it('getConfig returns empty array when no client set', function () { + $engine = new MockWebSocketEngine; + $client = new WebSocketClient($engine); + + expect($client->getConfig())->toBe([]); +}); + +it('getClient returns null when no client set', function () { + $engine = new MockWebSocketEngine; + $client = new WebSocketClient($engine); + + expect($client->getClient())->toBeNull(); +}); + +it('getLogger returns NullLogger when no logger or client set', function () { + $engine = new MockWebSocketEngine; + $client = new WebSocketClient($engine); + + expect($client->getLogger())->toBeInstanceOf(\Psr\Log\NullLogger::class); +}); + +it('getLogger returns explicit logger when set', function () { + $engine = new MockWebSocketEngine; + $logger = new \Psr\Log\NullLogger; + $client = new WebSocketClient($engine, null, $logger); + + expect($client->getLogger())->toBe($logger); +}); + +it('getLogger falls back to client logger when no explicit logger', function () { + $baseClient = ClientBuilder::create() + ->setCredentials('key', 'secret') + ->build(); + + $engine = new MockWebSocketEngine; + $client = new WebSocketClient($engine, $baseClient); + + expect($client->getLogger())->toBe($baseClient->getLogger()); +});