From 63f45e0b397be19bb53adf5724def2cd559c9462 Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Thu, 2 Oct 2025 23:17:04 +0200 Subject: [PATCH 01/18] Add full API --- init.php | 9 +- lib/HttpClient.php | 161 ++++++++++++++++++ lib/Notification.php | 189 ++++++++++----------- lib/Project.php | 89 ++++++++++ lib/Pushpad.php | 60 ++++++- lib/Resource.php | 203 ++++++++++++++++++++++ lib/Sender.php | 85 ++++++++++ lib/Subscription.php | 150 ++++++++++++++++ tests/NotificationTest.php | 340 +++++++++++++++++++++++++++---------- tests/ProjectTest.php | 210 +++++++++++++++++++++++ tests/ResourceTest.php | 276 ++++++++++++++++++++++++++++++ tests/SenderTest.php | 190 +++++++++++++++++++++ tests/SubscriptionTest.php | 227 +++++++++++++++++++++++++ 13 files changed, 1991 insertions(+), 198 deletions(-) create mode 100644 lib/HttpClient.php create mode 100644 lib/Project.php create mode 100644 lib/Resource.php create mode 100644 lib/Sender.php create mode 100644 lib/Subscription.php create mode 100644 tests/ProjectTest.php create mode 100644 tests/ResourceTest.php create mode 100644 tests/SenderTest.php create mode 100644 tests/SubscriptionTest.php diff --git a/init.php b/init.php index 335c000..7e3aaab 100644 --- a/init.php +++ b/init.php @@ -1,4 +1,9 @@ authToken = $authToken; + $this->baseUrl = rtrim($baseUrl, '/'); + $this->timeout = $timeout; + $this->userAgent = $userAgent ?? 'pushpad-php-sdk/1.0'; + } + + /** + * @param array{query?:array, json?:mixed, body?:string, headers?:array, timeout?:int} $options + * @return array{status:int, body:mixed, headers:array>, raw_body:?string} + */ + public function request(string $method, string $path, array $options = []): array + { + $url = $this->buildUrl($path, $options['query'] ?? []); + $payload = null; + $headers = $this->defaultHeaders(); + + if (isset($options['json'])) { + $payload = json_encode($options['json']); + if ($payload === false) { + throw new \RuntimeException('Failed to encode JSON payload.'); + } + $headers[] = 'Content-Type: application/json'; + } elseif (isset($options['body'])) { + $payload = (string) $options['body']; + } + + if (!empty($options['headers'])) { + $headers = array_merge($headers, $options['headers']); + } + + $timeout = isset($options['timeout']) ? (int) $options['timeout'] : $this->timeout; + + $responseHeaders = []; + $handle = curl_init($url); + if ($handle === false) { + throw new \RuntimeException('Unable to initialize cURL'); + } + + curl_setopt($handle, CURLOPT_CUSTOMREQUEST, strtoupper($method)); + curl_setopt($handle, CURLOPT_RETURNTRANSFER, true); + curl_setopt($handle, CURLOPT_TIMEOUT, $timeout); + curl_setopt($handle, CURLOPT_HTTPHEADER, $headers); + curl_setopt($handle, CURLOPT_USERAGENT, $this->userAgent); + curl_setopt($handle, CURLOPT_FOLLOWLOCATION, false); + curl_setopt($handle, CURLOPT_HEADER, false); + curl_setopt($handle, CURLOPT_HEADERFUNCTION, function ($curl, string $line) use (&$responseHeaders): int { + $trimmed = trim($line); + if ($trimmed === '' || stripos($trimmed, 'HTTP/') === 0) { + return strlen($line); + } + [$name, $value] = array_map('trim', explode(':', $trimmed, 2)); + $key = strtolower($name); + $responseHeaders[$key] = $responseHeaders[$key] ?? []; + $responseHeaders[$key][] = $value; + return strlen($line); + }); + + if ($payload !== null) { + curl_setopt($handle, CURLOPT_POSTFIELDS, $payload); + } + + $rawBody = curl_exec($handle); + if ($rawBody === false) { + $errorMessage = curl_error($handle); + curl_close($handle); + throw new \RuntimeException('cURL request error: ' . $errorMessage); + } + + $status = (int) curl_getinfo($handle, CURLINFO_HTTP_CODE); + curl_close($handle); + + return [ + 'status' => $status, + 'body' => $this->decode($rawBody), + 'headers' => $responseHeaders, + 'raw_body' => $rawBody === '' ? null : $rawBody, + ]; + } + + private function defaultHeaders(): array + { + return [ + 'Authorization: Bearer ' . $this->authToken, + 'Accept: application/json', + ]; + } + + private function buildUrl(string $path, array $query): string + { + $url = $this->baseUrl . '/' . ltrim($path, '/'); + if (!empty($query)) { + $queryString = $this->buildQueryString($query); + if ($queryString !== '') { + $url .= '?' . $queryString; + } + } + + return $url; + } + + /** + * @param array $query + */ + private function buildQueryString(array $query): string + { + $parts = []; + foreach ($query as $key => $value) { + if ($value === null) { + continue; + } + + if (is_array($value)) { + foreach ($value as $item) { + if ($item === null) { + continue; + } + $parts[] = rawurlencode($key . '[]') . '=' . rawurlencode((string) $item); + } + continue; + } + + $parts[] = rawurlencode((string) $key) . '=' . rawurlencode((string) $value); + } + + return implode('&', $parts); + } + + private function decode(string $rawBody) + { + $trimmed = trim($rawBody); + if ($trimmed === '') { + return null; + } + + $decoded = json_decode($trimmed, true); + if (json_last_error() === JSON_ERROR_NONE) { + return $decoded; + } + + return $trimmed; + } +} diff --git a/lib/Notification.php b/lib/Notification.php index d8e6218..3114658 100644 --- a/lib/Notification.php +++ b/lib/Notification.php @@ -1,111 +1,106 @@ + */ + public static function findAll(?int $projectId = null, array $query = []): array + { + $resolvedProjectId = Pushpad::resolveProjectId($projectId); + $response = self::httpGet("/projects/{$resolvedProjectId}/notifications", [ + 'query' => $query, + ]); + self::ensureStatus($response, 200); - function __construct($options = array()) { - $this->body = $options['body']; - if (isset($options['title'])) $this->title = $options['title']; - if (isset($options['target_url'])) $this->target_url = $options['target_url']; - if (isset($options['icon_url'])) $this->icon_url = $options['icon_url']; - if (isset($options['badge_url'])) $this->badge_url = $options['badge_url']; - if (isset($options['image_url'])) $this->image_url = $options['image_url']; - if (isset($options['ttl'])) $this->ttl = $options['ttl']; - if (isset($options['require_interaction'])) $this->require_interaction = $options['require_interaction']; - if (isset($options['silent'])) $this->silent = $options['silent']; - if (isset($options['urgent'])) $this->urgent = $options['urgent']; - if (isset($options['custom_data'])) $this->custom_data = $options['custom_data']; - if (isset($options['custom_metrics'])) $this->custom_metrics = $options['custom_metrics']; - if (isset($options['actions'])) $this->actions = $options['actions']; - if (isset($options['starred'])) $this->starred = $options['starred']; - if (isset($options['send_at'])) $this->send_at = $options['send_at']; - } + $items = $response['body']; + + return array_map(fn (array $item) => new self($item), $items); + } - public function broadcast($options = array()) { - return $this->deliver($this->req_body(null, isset($options['tags']) ? $options['tags'] : null), $options); - } + public static function find(int $notificationId): self + { + $response = self::httpGet("/notifications/{$notificationId}"); + self::ensureStatus($response, 200); + $data = $response['body']; - public function deliver_to($uids, $options = array()) { - if (!isset($uids)) { - $uids = array(); // prevent broadcasting + return new self($data); } - return $this->deliver($this->req_body($uids, isset($options['tags']) ? $options['tags'] : null), $options); - } - private function deliver($req_body, $options = array()) { - $project_id = isset($options['project_id']) ? $options['project_id'] : Pushpad::$project_id; - if (!isset($project_id)) throw new \Exception('You must set Pushpad\Pushpad::$project_id'); - $endpoint = "https://pushpad.xyz/api/v1/projects/$project_id/notifications"; - $req = curl_init($endpoint); - curl_setopt($req, CURLOPT_CUSTOMREQUEST, 'POST'); - curl_setopt($req, CURLOPT_POSTFIELDS, $req_body); - curl_setopt($req, CURLOPT_RETURNTRANSFER, true); - curl_setopt($req, CURLOPT_HTTPHEADER, $this->req_headers()); - $res = curl_exec($req); - if ($res === false) throw new NotificationDeliveryError("cURL request error: " . curl_error($req)); - $status_code = curl_getinfo($req, CURLINFO_HTTP_CODE); - curl_close($req); - if ($status_code != '201') throw new NotificationDeliveryError("Response $status_code: $res"); - return json_decode($res, true); - } + public static function create(array $payload, ?int $projectId = null): self + { + $resolvedProjectId = Pushpad::resolveProjectId($projectId); + $response = self::httpPost("/projects/{$resolvedProjectId}/notifications", [ + 'json' => $payload, + ]); + self::ensureStatus($response, 201); + $data = $response['body']; - private function req_headers() { - if (!isset(Pushpad::$auth_token)) throw new \Exception('You must set Pushpad\Pushpad::$auth_token'); - return array( - 'Authorization: Token token="' . Pushpad::$auth_token . '"', - 'Content-Type: application/json;charset=UTF-8', - 'Accept: application/json' - ); - } + return new self($data); + } - private function req_body($uids = null, $tags = null) { - $body = array( - 'notification' => array( - 'body' => $this->body - ) - ); - if (isset($this->title)) $body['notification']['title'] = $this->title; - if (isset($this->target_url)) $body['notification']['target_url'] = $this->target_url; - if (isset($this->icon_url)) $body['notification']['icon_url'] = $this->icon_url; - if (isset($this->badge_url)) $body['notification']['badge_url'] = $this->badge_url; - if (isset($this->image_url)) $body['notification']['image_url'] = $this->image_url; - if (isset($this->ttl)) $body['notification']['ttl'] = $this->ttl; - if (isset($this->require_interaction)) $body['notification']['require_interaction'] = $this->require_interaction; - if (isset($this->silent)) $body['notification']['silent'] = $this->silent; - if (isset($this->urgent)) $body['notification']['urgent'] = $this->urgent; - if (isset($this->custom_data)) $body['notification']['custom_data'] = $this->custom_data; - if (isset($this->custom_metrics)) $body['notification']['custom_metrics'] = $this->custom_metrics; - if (isset($this->actions)) $body['notification']['actions'] = $this->actions; - if (isset($this->starred)) $body['notification']['starred'] = $this->starred; - if (isset($this->send_at)) $body['notification']['send_at'] = date('c', $this->send_at); + public static function send(array $payload, ?int $projectId = null): self + { + return static::create($payload, $projectId); + } - if (isset($uids)) $body['uids'] = $uids; - if (isset($tags)) $body['tags'] = $tags; - $json = json_encode($body); - if ($json == false) - throw new \Exception('An error occurred while encoding the following request into JSON: ' . var_export($body, true)); - return $json; - } + public function cancel(): void + { + $response = self::httpDelete("/notifications/{$this->requireId()}/cancel"); + self::ensureStatus($response, 204); + $this->attributes['cancelled'] = true; + } + + public function refresh(): self + { + $response = self::httpGet("/notifications/{$this->requireId()}"); + self::ensureStatus($response, 200); + $data = $response['body']; + $this->setAttributes($data); + return $this; + } } diff --git a/lib/Project.php b/lib/Project.php new file mode 100644 index 0000000..2c06a6c --- /dev/null +++ b/lib/Project.php @@ -0,0 +1,89 @@ + + */ + public static function findAll(): array + { + $response = self::httpGet('/projects'); + self::ensureStatus($response, 200); + $items = $response['body']; + + return array_map(fn (array $item) => new self($item), $items); + } + + public static function find(int $projectId): self + { + $response = self::httpGet("/projects/{$projectId}"); + self::ensureStatus($response, 200); + $data = $response['body']; + + return new self($data); + } + + public static function create(array $payload): self + { + $response = self::httpPost('/projects', [ + 'json' => self::filterForCreatePayload($payload), + ]); + self::ensureStatus($response, 201); + $data = $response['body']; + + return new self($data); + } + + public function refresh(): self + { + $response = self::httpGet("/projects/{$this->requireId()}"); + self::ensureStatus($response, 200); + $data = $response['body']; + $this->setAttributes($data); + return $this; + } + + public function update(array $payload): self + { + $response = self::httpPatch("/projects/{$this->requireId()}", [ + 'json' => self::filterForUpdatePayload($payload), + ]); + self::ensureStatus($response, 200); + $data = $response['body']; + $this->setAttributes($data); + return $this; + } + + public function delete(): void + { + $response = self::httpDelete("/projects/{$this->requireId()}"); + self::ensureStatus($response, 202); + } +} diff --git a/lib/Pushpad.php b/lib/Pushpad.php index 39c4906..25e639a 100644 --- a/lib/Pushpad.php +++ b/lib/Pushpad.php @@ -1,14 +1,60 @@ */ + protected array $attributes; + + /** + * @param array $attributes + */ + public function __construct(array $attributes = []) + { + $this->attributes = $this->filterStoredAttributes($attributes); + } + + /** + * @return array + */ + public function toArray(): array + { + return $this->attributes; + } + + public function jsonSerialize(): mixed + { + return $this->attributes; + } + + public function __get(string $name) + { + if (!in_array($name, static::attributes(), true)) { + throw new \InvalidArgumentException(sprintf('Unknown attribute "%s" for %s', $name, static::class)); + } + + return $this->attributes[$name] ?? null; + } + + public function __isset(string $name): bool + { + return in_array($name, static::attributes(), true) && array_key_exists($name, $this->attributes); + } + + protected function setAttributes(array $attributes): void + { + $this->attributes = $this->filterStoredAttributes($attributes); + } + + protected function requireId(): int + { + if (!isset($this->attributes['id'])) { + throw new \LogicException('Resource does not have an id yet.'); + } + + return (int) $this->attributes['id']; + } + + protected static function http(): HttpClient + { + return Pushpad::http(); + } + + /** + * @return array{status:int, body:mixed, headers:array>|null, raw_body:?string} + */ + protected static function httpGet(string $path, array $options = []): array + { + return self::http()->request('GET', $path, $options); + } + + /** + * @return array{status:int, body:mixed, headers:array>|null, raw_body:?string} + */ + protected static function httpPost(string $path, array $options = []): array + { + return self::http()->request('POST', $path, $options); + } + + /** + * @return array{status:int, body:mixed, headers:array>|null, raw_body:?string} + */ + protected static function httpPatch(string $path, array $options = []): array + { + return self::http()->request('PATCH', $path, $options); + } + + /** + * @return array{status:int, body:mixed, headers:array>|null, raw_body:?string} + */ + protected static function httpDelete(string $path, array $options = []): array + { + return self::http()->request('DELETE', $path, $options); + } + + /** + * @param array{status:int} $response + */ + protected static function ensureStatus(array $response, int $expectedStatusCode): void + { + $status = $response['status'] ?? 0; + if ($status !== $expectedStatusCode) { + throw new \UnexpectedValueException( + sprintf( + 'Unexpected status code %d. Expected %d.', + $status, + $expectedStatusCode + ) + ); + } + } + + /** + * @param array $attributes + */ + protected static function filterForCreatePayload(array $attributes): array + { + $allowed = array_diff( + static::attributes(), + static::readOnlyAttributes() + ); + + return self::filterToAllowedAttributes($attributes, $allowed, true); + } + + /** + * @param array $attributes + */ + protected static function filterForUpdatePayload(array $attributes): array + { + $allowed = array_diff( + static::attributes(), + static::readOnlyAttributes(), + static::immutableAttributes() + ); + + return self::filterToAllowedAttributes($attributes, $allowed, true); + } + + /** + * @param array $attributes + */ + private function filterStoredAttributes(array $attributes): array + { + return self::filterToAllowedAttributes($attributes, static::attributes()); + } + + /** + * @param array $attributes + * @param array $allowed + * @return array + */ + private static function filterToAllowedAttributes(array $attributes, array $allowed, bool $rejectUnknown = false): array + { + if ($allowed === []) { + return $attributes; + } + + $allowedMap = array_flip($allowed); + $filtered = []; + + foreach ($attributes as $key => $value) { + if (!isset($allowedMap[$key])) { + if ($rejectUnknown) { + throw new \InvalidArgumentException(sprintf('Unknown attribute "%s" for %s', $key, static::class)); + } + + continue; + } + + $filtered[$key] = $value; + unset($allowedMap[$key]); + } + + return $filtered; + } + + /** + * @param array $attributes + * @param array $keys + * @return array + */ + private static function attributes(): array + { + return defined('static::ATTRIBUTES') ? static::ATTRIBUTES : []; + } + + private static function readOnlyAttributes(): array + { + return defined('static::READ_ONLY_ATTRIBUTES') ? static::READ_ONLY_ATTRIBUTES : []; + } + + private static function immutableAttributes(): array + { + return defined('static::IMMUTABLE_ATTRIBUTES') ? static::IMMUTABLE_ATTRIBUTES : []; + } + +} diff --git a/lib/Sender.php b/lib/Sender.php new file mode 100644 index 0000000..d9d557f --- /dev/null +++ b/lib/Sender.php @@ -0,0 +1,85 @@ + + */ + public static function findAll(): array + { + $response = self::httpGet('/senders'); + self::ensureStatus($response, 200); + $items = $response['body']; + + return array_map(fn (array $item) => new self($item), $items); + } + + public static function find(int $senderId): self + { + $response = self::httpGet("/senders/{$senderId}"); + self::ensureStatus($response, 200); + $data = $response['body']; + + return new self($data); + } + + public static function create(array $payload): self + { + $response = self::httpPost('/senders', [ + 'json' => self::filterForCreatePayload($payload), + ]); + self::ensureStatus($response, 201); + $data = $response['body']; + + return new self($data); + } + + public function refresh(): self + { + $response = self::httpGet("/senders/{$this->requireId()}"); + self::ensureStatus($response, 200); + $data = $response['body']; + $this->setAttributes($data); + return $this; + } + + public function update(array $payload): self + { + $response = self::httpPatch("/senders/{$this->requireId()}", [ + 'json' => self::filterForUpdatePayload($payload), + ]); + self::ensureStatus($response, 200); + $data = $response['body']; + $this->setAttributes($data); + return $this; + } + + public function delete(): void + { + $response = self::httpDelete("/senders/{$this->requireId()}"); + self::ensureStatus($response, 204); + } +} diff --git a/lib/Subscription.php b/lib/Subscription.php new file mode 100644 index 0000000..11be6e2 --- /dev/null +++ b/lib/Subscription.php @@ -0,0 +1,150 @@ + + */ + public static function findAll(?int $projectId = null, array $query = []): array + { + $resolvedProjectId = Pushpad::resolveProjectId($projectId); + $response = self::httpGet("/projects/{$resolvedProjectId}/subscriptions", [ + 'query' => $query, + ]); + + self::ensureStatus($response, 200); + $items = $response['body']; + + return array_map( + fn (array $item) => new self(self::injectProjectId($item, $resolvedProjectId)), + $items + ); + } + + public static function count(?int $projectId = null, array $query = []): int + { + $resolvedProjectId = Pushpad::resolveProjectId($projectId); + $response = self::httpGet("/projects/{$resolvedProjectId}/subscriptions", [ + 'query' => $query, + ]); + + self::ensureStatus($response, 200); + $headers = $response['headers'] ?? []; + $name = 'x-total-count'; + + if (!isset($headers[$name][0]) || !is_numeric($headers[$name][0])) { + throw new \UnexpectedValueException('Response is missing the X-Total-Count header.'); + } + + return (int) $headers[$name][0]; + } + + public static function find(int $subscriptionId, ?int $projectId = null): self + { + $resolvedProjectId = Pushpad::resolveProjectId($projectId); + $response = self::httpGet("/projects/{$resolvedProjectId}/subscriptions/{$subscriptionId}"); + self::ensureStatus($response, 200); + $data = $response['body']; + + return new self(self::injectProjectId($data, $resolvedProjectId)); + } + + public static function create(array $payload, ?int $projectId = null): self + { + $resolvedProjectId = Pushpad::resolveProjectId($projectId); + $body = self::filterForCreatePayload($payload); + $response = self::httpPost("/projects/{$resolvedProjectId}/subscriptions", [ + 'json' => $body, + ]); + self::ensureStatus($response, 201); + $data = $response['body']; + + return new self(self::injectProjectId($data, $resolvedProjectId)); + } + + public function refresh(?int $projectId = null): self + { + $project = $this->determineProjectId($projectId); + $response = self::httpGet("/projects/{$project}/subscriptions/{$this->requireId()}"); + self::ensureStatus($response, 200); + $data = $response['body']; + $this->setAttributes(self::injectProjectId($data, $project)); + return $this; + } + + public function update(array $payload, ?int $projectId = null): self + { + $project = $this->determineProjectId($projectId); + $body = self::filterForUpdatePayload($payload); + $response = self::httpPatch("/projects/{$project}/subscriptions/{$this->requireId()}", [ + 'json' => $body, + ]); + self::ensureStatus($response, 200); + $data = $response['body']; + $this->setAttributes(self::injectProjectId($data, $project)); + return $this; + } + + public function delete(?int $projectId = null): void + { + $project = $this->determineProjectId($projectId); + $response = self::httpDelete("/projects/{$project}/subscriptions/{$this->requireId()}"); + self::ensureStatus($response, 204); + } + + private function determineProjectId(?int $projectId = null): int + { + if ($projectId !== null) { + return $projectId; + } + + if (isset($this->attributes['project_id'])) { + return (int) $this->attributes['project_id']; + } + + return Pushpad::resolveProjectId(null); + } + + /** + * @param array $data + * @return array + */ + private static function injectProjectId(array $data, int $projectId): array + { + if (!isset($data['project_id'])) { + $data['project_id'] = $projectId; + } + + return $data; + } +} diff --git a/tests/NotificationTest.php b/tests/NotificationTest.php index c80ef70..5813534 100644 --- a/tests/NotificationTest.php +++ b/tests/NotificationTest.php @@ -1,97 +1,253 @@ notification = new Notification([ - 'body' => 'Test body', - 'title' => 'Test title', - 'target_url' => 'https://example.com' - ]); - } - - public function testNotificationInitialization() { - $this->assertEquals('Test body', $this->notification->body); - $this->assertEquals('Test title', $this->notification->title); - $this->assertEquals('https://example.com', $this->notification->target_url); - } - - public function testBroadcastNotification() { - $mockResponse = json_encode(['id' => 123, 'scheduled' => 5000]); - $this->mockCurl($mockResponse, 201); - - $response = $this->notification->broadcast(); - $this->assertArrayHasKey('id', $response); - $this->assertEquals(123, $response['id']); - $this->assertArrayHasKey('scheduled', $response); - $this->assertEquals(5000, $response['scheduled']); - } - - public function testBroadcastNotificationWithTags() { - $mockResponse = json_encode(['id' => 789, 'scheduled' => 1000]); - $this->mockCurl($mockResponse, 201); - - $response = $this->notification->broadcast(['tags' => ['segment1', 'segment2']]); - $this->assertArrayHasKey('id', $response); - $this->assertEquals(789, $response['id']); - $this->assertArrayHasKey('scheduled', $response); - $this->assertEquals(1000, $response['scheduled']); - } - - public function testDeliverToSpecificUsers() { - $mockResponse = json_encode(['id' => 456, 'scheduled' => 1, 'uids' => ['user1']]); - $this->mockCurl($mockResponse, 201); - - $response = $this->notification->deliver_to(['user1', 'user2']); - $this->assertArrayHasKey('id', $response); - $this->assertEquals(456, $response['id']); - $this->assertArrayHasKey('scheduled', $response); - $this->assertEquals(1, $response['scheduled']); - $this->assertArrayHasKey('uids', $response); - $this->assertEquals(['user1'], $response['uids']); - } - - public function testMissingAuthTokenThrowsException() { - Pushpad::$auth_token = null; - - $this->expectException(Exception::class); - $this->notification->broadcast(); - } - - public function testMissingProjectIdThrowsException() { - Pushpad::$project_id = null; - - $this->expectException(Exception::class); - $this->notification->broadcast(); - } - - public function testInvalidResponseCodeThrowsError() { - $this->mockCurl('Error message', 400); - - $this->expectException(NotificationDeliveryError::class); - $this->notification->broadcast(); - } - - private function mockCurl($responseBody, $statusCode) { - $mockCurl = $this->getFunctionMock('Pushpad', 'curl_exec'); - $mockCurl->expects($this->any())->willReturn($responseBody); - - $mockInfo = $this->getFunctionMock('Pushpad', 'curl_getinfo'); - $mockInfo->expects($this->any())->willReturn($statusCode); - - $mockClose = $this->getFunctionMock('Pushpad', 'curl_close'); - $mockClose->expects($this->any())->willReturn(null); - } +use Pushpad\Pushpad; + +class NotificationTest extends TestCase +{ + protected function setUp(): void + { + Pushpad::$auth_token = 'token'; + Pushpad::$project_id = 123; + } + + protected function tearDown(): void + { + Pushpad::setHttpClient(null); + Pushpad::$auth_token = null; + Pushpad::$project_id = null; + } + + public function testFindAllReturnsNotifications(): void + { + $httpClient = $this->createMock(HttpClient::class); + $responseBody = [ + [ + 'id' => 197123, + 'project_id' => 123, + 'title' => 'Black Friday Deals', + 'body' => 'Enjoy 50% off on all items!', + 'target_url' => 'https://example.com/deals', + 'created_at' => '2025-09-14T10:30:00.123Z', + 'scheduled' => false, + ], + [ + 'id' => 197124, + 'project_id' => 123, + 'title' => 'Cyber Monday', + 'body' => 'Exclusive online offers.', + 'target_url' => 'https://example.com/cyber', + 'created_at' => '2025-09-15T10:30:00.123Z', + 'scheduled' => false, + ], + ]; + + $httpClient + ->expects($this->once()) + ->method('request') + ->with('GET', '/projects/123/notifications', ['query' => ['page' => 1]]) + ->willReturn([ + 'status' => 200, + 'body' => $responseBody, + 'headers' => [], + 'raw_body' => null, + ]); + + Pushpad::setHttpClient($httpClient); + + $notifications = Notification::findAll(null, ['page' => 1]); + + $this->assertCount(2, $notifications); + $this->assertSame('Black Friday Deals', $notifications[0]->title); + $this->assertSame('Cyber Monday', $notifications[1]->title); + + $this->expectException(InvalidArgumentException::class); + $unused = $notifications[0]->undefined_property; + } + + public function testFindReturnsNotification(): void + { + $httpClient = $this->createMock(HttpClient::class); + $httpClient + ->expects($this->once()) + ->method('request') + ->with('GET', '/notifications/197123', []) + ->willReturn([ + 'status' => 200, + 'body' => [ + 'id' => 197123, + 'project_id' => 123, + 'title' => 'Order Shipped', + 'body' => 'Your order has been shipped.', + 'created_at' => '2025-09-14T10:30:00.123Z', + ], + 'headers' => [], + 'raw_body' => null, + ]); + + Pushpad::setHttpClient($httpClient); + + $notification = Notification::find(197123); + + $this->assertInstanceOf(Notification::class, $notification); + $this->assertSame('Order Shipped', $notification->title); + } + + public function testCreateNotificationSendsPayload(): void + { + $payload = [ + 'notification' => [ + 'title' => 'New Feature', + 'body' => 'Try our new feature today!', + 'target_url' => 'https://example.com/new-feature', + 'icon_url' => 'https://example.com/icon.png', + ], + 'uids' => ['user1', 'user2'], + 'tags' => ['beta-testers'], + ]; + + $httpClient = $this->createMock(HttpClient::class); + $httpClient + ->expects($this->once()) + ->method('request') + ->with( + 'POST', + '/projects/123/notifications', + $this->callback(function (array $options) use ($payload): bool { + $this->assertSame($payload, $options['json']); + return true; + }) + ) + ->willReturn([ + 'status' => 201, + 'body' => [ + 'id' => 200001, + 'project_id' => 123, + 'title' => 'New Feature', + 'body' => 'Try our new feature today!', + 'target_url' => 'https://example.com/new-feature', + 'created_at' => '2025-09-16T09:00:00.000Z', + ], + 'headers' => [], + 'raw_body' => null, + ]); + + Pushpad::setHttpClient($httpClient); + + $notification = Notification::create($payload); + + $this->assertSame(200001, $notification->id); + $this->assertSame('New Feature', $notification->title); + } + + public function testSendNotificationUsesCreate(): void + { + $payload = [ + 'notification' => [ + 'title' => 'Weekly Update', + 'body' => 'A recap of the week.', + 'target_url' => 'https://example.com/update', + ], + ]; + + $httpClient = $this->createMock(HttpClient::class); + $httpClient + ->expects($this->once()) + ->method('request') + ->with( + 'POST', + '/projects/123/notifications', + $this->callback(function (array $options) use ($payload): bool { + $this->assertSame($payload, $options['json']); + return true; + }) + ) + ->willReturn([ + 'status' => 201, + 'body' => [ + 'id' => 210000, + 'project_id' => 123, + 'title' => 'Weekly Update', + 'body' => 'A recap of the week.', + 'target_url' => 'https://example.com/update', + 'created_at' => '2025-09-17T08:00:00.000Z', + ], + 'headers' => [], + 'raw_body' => null, + ]); + + Pushpad::setHttpClient($httpClient); + + $notification = Notification::send($payload); + + $this->assertSame(210000, $notification->id); + $this->assertSame('Weekly Update', $notification->title); + } + + public function testCancelNotification(): void + { + $httpClient = $this->createMock(HttpClient::class); + $httpClient + ->expects($this->once()) + ->method('request') + ->with('DELETE', '/notifications/197123/cancel', []) + ->willReturn([ + 'status' => 204, + 'body' => null, + 'headers' => [], + 'raw_body' => null, + ]); + + Pushpad::setHttpClient($httpClient); + + $notification = new Notification([ + 'id' => 197123, + 'title' => 'Sale Reminder', + 'cancelled' => false, + ]); + + $notification->cancel(); + + $this->assertTrue($notification->cancelled); + } + + public function testRefreshNotificationUpdatesAttributes(): void + { + $httpClient = $this->createMock(HttpClient::class); + $httpClient + ->expects($this->once()) + ->method('request') + ->with('GET', '/notifications/197123', []) + ->willReturn([ + 'status' => 200, + 'body' => [ + 'id' => 197123, + 'project_id' => 123, + 'title' => 'Updated Title', + 'body' => 'Updated body copy.', + 'target_url' => 'https://example.com/new', + 'created_at' => '2025-09-14T10:30:00.123Z', + ], + 'headers' => [], + 'raw_body' => null, + ]); + + Pushpad::setHttpClient($httpClient); + + $notification = new Notification([ + 'id' => 197123, + 'project_id' => 123, + 'title' => 'Old Title', + 'body' => 'Old body.', + ]); + + $notification->refresh(); + + $this->assertSame('Updated Title', $notification->title); + $this->assertSame('Updated body copy.', $notification->body); + } } diff --git a/tests/ProjectTest.php b/tests/ProjectTest.php new file mode 100644 index 0000000..2bbc4d3 --- /dev/null +++ b/tests/ProjectTest.php @@ -0,0 +1,210 @@ +createMock(HttpClient::class); + $httpClient + ->expects($this->once()) + ->method('request') + ->with('GET', '/projects', []) + ->willReturn([ + 'status' => 200, + 'body' => [ + [ + 'id' => 12345, + 'sender_id' => 98765, + 'name' => 'My Project', + 'website' => 'https://example.com', + 'icon_url' => 'https://example.com/icon.png', + 'badge_url' => 'https://example.com/badge.png', + 'notifications_ttl' => 604800, + 'notifications_require_interaction' => false, + 'notifications_silent' => false, + 'created_at' => '2025-09-14T10:30:00.123Z', + ], + ], + 'headers' => [], + 'raw_body' => null, + ]); + + Pushpad::setHttpClient($httpClient); + + $projects = Project::findAll(); + + $this->assertCount(1, $projects); + $this->assertSame('My Project', $projects[0]->name); + } + + public function testFindReturnsProject(): void + { + $httpClient = $this->createMock(HttpClient::class); + $httpClient + ->expects($this->once()) + ->method('request') + ->with('GET', '/projects/12345', []) + ->willReturn([ + 'status' => 200, + 'body' => [ + 'id' => 12345, + 'sender_id' => 98765, + 'name' => 'My Project', + 'website' => 'https://example.com', + 'created_at' => '2025-09-14T10:30:00.123Z', + ], + 'headers' => [], + 'raw_body' => null, + ]); + + Pushpad::setHttpClient($httpClient); + + $project = Project::find(12345); + + $this->assertSame('My Project', $project->name); + } + + public function testCreateProjectSendsPayload(): void + { + $payload = [ + 'sender_id' => 98765, + 'name' => 'New Project', + 'website' => 'https://example.com/new', + 'icon_url' => 'https://example.com/icon.png', + 'badge_url' => 'https://example.com/badge.png', + 'notifications_ttl' => 604800, + 'notifications_require_interaction' => false, + 'notifications_silent' => false, + ]; + + $httpClient = $this->createMock(HttpClient::class); + $httpClient + ->expects($this->once()) + ->method('request') + ->with( + 'POST', + '/projects', + $this->callback(function (array $options) use ($payload): bool { + $this->assertSame($payload, $options['json']); + return true; + }) + ) + ->willReturn([ + 'status' => 201, + 'body' => $payload + [ + 'id' => 55555, + 'created_at' => '2025-09-14T10:30:00.123Z', + ], + 'headers' => [], + 'raw_body' => null, + ]); + + Pushpad::setHttpClient($httpClient); + + $project = Project::create($payload); + + $this->assertSame(55555, $project->id); + $this->assertSame('New Project', $project->name); + } + + public function testCreateProjectRejectsReadOnlyAttribute(): void + { + $payload = [ + 'sender_id' => 98765, + 'name' => 'Fails', + 'website' => 'https://example.com/fails', + 'id' => 1, + ]; + + $this->expectException(InvalidArgumentException::class); + Project::create($payload); + } + + public function testUpdateProjectSendsPayload(): void + { + $httpClient = $this->createMock(HttpClient::class); + $httpClient + ->expects($this->once()) + ->method('request') + ->with('PATCH', '/projects/12345', ['json' => ['name' => 'Updated Project']]) + ->willReturn([ + 'status' => 200, + 'body' => [ + 'id' => 12345, + 'sender_id' => 98765, + 'name' => 'Updated Project', + 'website' => 'https://example.com', + ], + 'headers' => [], + 'raw_body' => null, + ]); + + Pushpad::setHttpClient($httpClient); + + $project = new Project([ + 'id' => 12345, + 'sender_id' => 98765, + 'name' => 'My Project', + 'website' => 'https://example.com', + ]); + + $project->update(['name' => 'Updated Project']); + + $this->assertSame('Updated Project', $project->name); + } + + public function testUpdateProjectRejectsImmutableAttributes(): void + { + $project = new Project([ + 'id' => 12345, + 'sender_id' => 98765, + 'name' => 'My Project', + 'website' => 'https://example.com', + ]); + + $this->expectException(InvalidArgumentException::class); + $project->update(['sender_id' => 123]); + } + + public function testDeleteProject(): void + { + $httpClient = $this->createMock(HttpClient::class); + $httpClient + ->expects($this->once()) + ->method('request') + ->with('DELETE', '/projects/12345', []) + ->willReturn([ + 'status' => 202, + 'body' => null, + 'headers' => [], + 'raw_body' => null, + ]); + + Pushpad::setHttpClient($httpClient); + + $project = new Project([ + 'id' => 12345, + 'name' => 'My Project', + ]); + + $project->delete(); + } +} diff --git a/tests/ResourceTest.php b/tests/ResourceTest.php new file mode 100644 index 0000000..704aadd --- /dev/null +++ b/tests/ResourceTest.php @@ -0,0 +1,276 @@ + 5, + 'name' => 'demo', + 'read_only' => 'keep', + 'extra' => 'ignored', + ]); + + $this->assertSame( + ['id' => 5, 'name' => 'demo', 'read_only' => 'keep'], + $resource->toArray() + ); + } + + public function testJsonSerializeReturnsStoredAttributes(): void + { + $resource = new DummyResource([ + 'id' => 10, + 'name' => 'serializable', + ]); + + $this->assertSame([ + 'id' => 10, + 'name' => 'serializable', + ], $resource->jsonSerialize()); + + $this->assertSame('{"id":10,"name":"serializable"}', json_encode($resource)); + } + + public function testGetKnownAttributeReturnsValue(): void + { + $resource = new DummyResource(['name' => 'Ada']); + + $this->assertSame('Ada', $resource->name); + } + + public function testGetUnknownAttributeThrows(): void + { + $resource = new DummyResource(['name' => 'Ada']); + + $this->expectException(\InvalidArgumentException::class); + $unused = $resource->unknown; + } + + public function testGetAllowedButMissingReturnsNull(): void + { + $resource = new DummyResource(); + + $this->assertNull($resource->name); + } + + public function testIssetReflectsPresenceOfStoredAttribute(): void + { + $resource = new DummyResource(['name' => 'Ada']); + + $this->assertTrue(isset($resource->name)); + $this->assertFalse(isset($resource->read_only)); + $this->assertFalse(isset($resource->unknown)); + } + + public function testSetAttributesReplacesStoredValues(): void + { + $resource = new DummyResource(['name' => 'first']); + $resource->exposeSetAttributes([ + 'name' => 'second', + 'extra' => 'ignored', + ]); + + $this->assertSame(['name' => 'second'], $resource->toArray()); + } + + public function testRequireIdReturnsInt(): void + { + $resource = new DummyResource(['id' => '7']); + + $this->assertSame(7, $resource->exposeRequireId()); + } + + public function testRequireIdThrowsWhenMissing(): void + { + $resource = new DummyResource(); + + $this->expectException(\LogicException::class); + $resource->exposeRequireId(); + } + + public function testFilterForCreatePayloadReturnsAllowedAttributes(): void + { + $payload = DummyResource::exposeFilterForCreatePayload([ + 'name' => 'New', + ]); + + $this->assertSame(['name' => 'New'], $payload); + } + + public function testFilterForCreatePayloadRejectsReadOnlyAttributes(): void + { + $this->expectException(\InvalidArgumentException::class); + DummyResource::exposeFilterForCreatePayload([ + 'name' => 'New', + 'read_only' => 'value', + ]); + } + + public function testFilterForCreatePayloadRejectsUnknownAttributes(): void + { + $this->expectException(\InvalidArgumentException::class); + DummyResource::exposeFilterForCreatePayload([ + 'name' => 'New', + 'unknown' => 'value', + ]); + } + + public function testFilterForUpdatePayloadReturnsAllowedAttributes(): void + { + $payload = DummyResource::exposeFilterForUpdatePayload([ + 'name' => 'Updated', + ]); + + $this->assertSame(['name' => 'Updated'], $payload); + } + + public function testFilterForUpdatePayloadRejectsNonWritableAttributes(): void + { + $this->expectException(\InvalidArgumentException::class); + DummyResource::exposeFilterForUpdatePayload([ + 'name' => 'Updated', + 'immutable' => 'value', + ]); + } + + public function testFilterForUpdatePayloadRejectsUnknownAttributes(): void + { + $this->expectException(\InvalidArgumentException::class); + DummyResource::exposeFilterForUpdatePayload([ + 'name' => 'Updated', + 'unknown' => 'value', + ]); + } + + public function testEnsureStatusAcceptsExpectedCode(): void + { + DummyResource::exposeEnsureStatus(['status' => 204], 204); + $this->addToAssertionCount(1); + } + + public function testEnsureStatusThrowsForUnexpectedCode(): void + { + $this->expectException(\UnexpectedValueException::class); + DummyResource::exposeEnsureStatus(['status' => 500], 204); + } + + public function testConstructorKeepsAttributesWhenNoListDefined(): void + { + $resource = new LooseResource(['foo' => 'bar', 'other' => 'value']); + + $this->assertSame([ + 'foo' => 'bar', + 'other' => 'value', + ], $resource->toArray()); + } + + /** + * @dataProvider httpMethodProvider + */ + public function testHttpHelpersProxyRequestsToHttpClient(string $method, string $wrapper): void + { + Pushpad::$auth_token = 'token'; + $httpClient = $this->createMock(HttpClient::class); + $response = [ + 'status' => 200, + 'body' => ['ok' => true], + 'headers' => ['Header' => ['Value']], + 'raw_body' => '{"ok":true}', + ]; + + $httpClient + ->expects($this->once()) + ->method('request') + ->with($method, '/path', ['query' => ['page' => 1]]) + ->willReturn($response); + + Pushpad::setHttpClient($httpClient); + + $result = DummyResource::{$wrapper}('/path', ['query' => ['page' => 1]]); + + $this->assertSame($response, $result); + } + + /** + * @return array + */ + public static function httpMethodProvider(): array + { + return [ + 'get' => ['GET', 'exposeHttpGet'], + 'post' => ['POST', 'exposeHttpPost'], + 'patch' => ['PATCH', 'exposeHttpPatch'], + 'delete' => ['DELETE', 'exposeHttpDelete'], + ]; + } +} + +class DummyResource extends Resource +{ + public const ATTRIBUTES = ['id', 'name', 'read_only', 'immutable']; + public const READ_ONLY_ATTRIBUTES = ['read_only']; + public const IMMUTABLE_ATTRIBUTES = ['immutable']; + + public function exposeSetAttributes(array $attributes): void + { + $this->setAttributes($attributes); + } + + public function exposeRequireId(): int + { + return $this->requireId(); + } + + public static function exposeFilterForCreatePayload(array $attributes): array + { + return self::filterForCreatePayload($attributes); + } + + public static function exposeFilterForUpdatePayload(array $attributes): array + { + return self::filterForUpdatePayload($attributes); + } + + public static function exposeEnsureStatus(array $response, int $expectedStatusCode): void + { + self::ensureStatus($response, $expectedStatusCode); + } + + public static function exposeHttpGet(string $path, array $options = []): array + { + return self::httpGet($path, $options); + } + + public static function exposeHttpPost(string $path, array $options = []): array + { + return self::httpPost($path, $options); + } + + public static function exposeHttpPatch(string $path, array $options = []): array + { + return self::httpPatch($path, $options); + } + + public static function exposeHttpDelete(string $path, array $options = []): array + { + return self::httpDelete($path, $options); + } +} + +class LooseResource extends Resource +{ +} diff --git a/tests/SenderTest.php b/tests/SenderTest.php new file mode 100644 index 0000000..58638ee --- /dev/null +++ b/tests/SenderTest.php @@ -0,0 +1,190 @@ +createMock(HttpClient::class); + $httpClient + ->expects($this->once()) + ->method('request') + ->with('GET', '/senders', []) + ->willReturn([ + 'status' => 200, + 'body' => [ + [ + 'id' => 5, + 'name' => 'Primary Sender', + 'created_at' => '2025-09-13T10:30:00.123Z', + ], + ], + 'headers' => [], + 'raw_body' => null, + ]); + + Pushpad::setHttpClient($httpClient); + + $senders = Sender::findAll(); + + $this->assertCount(1, $senders); + $this->assertSame('Primary Sender', $senders[0]->name); + } + + public function testFindReturnsSender(): void + { + $httpClient = $this->createMock(HttpClient::class); + $httpClient + ->expects($this->once()) + ->method('request') + ->with('GET', '/senders/5', []) + ->willReturn([ + 'status' => 200, + 'body' => [ + 'id' => 5, + 'name' => 'Primary Sender', + 'created_at' => '2025-09-13T10:30:00.123Z', + ], + 'headers' => [], + 'raw_body' => null, + ]); + + Pushpad::setHttpClient($httpClient); + + $sender = Sender::find(5); + + $this->assertSame('Primary Sender', $sender->name); + } + + public function testCreateSenderSendsPayload(): void + { + $payload = [ + 'name' => 'Marketing Sender', + ]; + + $httpClient = $this->createMock(HttpClient::class); + $httpClient + ->expects($this->once()) + ->method('request') + ->with( + 'POST', + '/senders', + $this->callback(function (array $options) use ($payload): bool { + $this->assertSame($payload, $options['json']); + return true; + }) + ) + ->willReturn([ + 'status' => 201, + 'body' => [ + 'id' => 10, + 'name' => 'Marketing Sender', + 'created_at' => '2025-09-13T12:00:00.000Z', + ], + 'headers' => [], + 'raw_body' => null, + ]); + + Pushpad::setHttpClient($httpClient); + + $sender = Sender::create($payload); + + $this->assertSame(10, $sender->id); + $this->assertSame('Marketing Sender', $sender->name); + } + + public function testCreateSenderRejectsReadOnlyAttribute(): void + { + $payload = [ + 'name' => 'Invalid Sender', + 'created_at' => '2025-09-13T10:30:00.123Z', + ]; + + $this->expectException(InvalidArgumentException::class); + Sender::create($payload); + } + + public function testUpdateSenderSendsPayload(): void + { + $httpClient = $this->createMock(HttpClient::class); + $httpClient + ->expects($this->once()) + ->method('request') + ->with('PATCH', '/senders/5', ['json' => ['name' => 'Renamed Sender']]) + ->willReturn([ + 'status' => 200, + 'body' => [ + 'id' => 5, + 'name' => 'Renamed Sender', + 'created_at' => '2025-09-13T10:30:00.123Z', + ], + 'headers' => [], + 'raw_body' => null, + ]); + + Pushpad::setHttpClient($httpClient); + + $sender = new Sender([ + 'id' => 5, + 'name' => 'Primary Sender', + 'created_at' => '2025-09-13T10:30:00.123Z', + ]); + + $sender->update(['name' => 'Renamed Sender']); + + $this->assertSame('Renamed Sender', $sender->name); + } + + public function testUpdateSenderRejectsImmutableAttributes(): void + { + $sender = new Sender([ + 'id' => 5, + 'name' => 'Primary Sender', + 'created_at' => '2025-09-13T10:30:00.123Z', + ]); + + $this->expectException(InvalidArgumentException::class); + $sender->update(['vapid_private_key' => 'forbidden']); + } + + public function testDeleteSender(): void + { + $httpClient = $this->createMock(HttpClient::class); + $httpClient + ->expects($this->once()) + ->method('request') + ->with('DELETE', '/senders/5', []) + ->willReturn([ + 'status' => 204, + 'body' => null, + 'headers' => [], + 'raw_body' => null, + ]); + + Pushpad::setHttpClient($httpClient); + + $sender = new Sender([ + 'id' => 5, + 'name' => 'Primary Sender', + ]); + + $sender->delete(); + } +} diff --git a/tests/SubscriptionTest.php b/tests/SubscriptionTest.php new file mode 100644 index 0000000..c524ef5 --- /dev/null +++ b/tests/SubscriptionTest.php @@ -0,0 +1,227 @@ +createMock(HttpClient::class); + $httpClient + ->expects($this->once()) + ->method('request') + ->with('GET', '/projects/321/subscriptions', ['query' => ['page' => 2]]) + ->willReturn([ + 'status' => 200, + 'body' => [ + [ + 'id' => 7, + 'endpoint' => 'https://example.com/push/f7Q1Eyf', + 'uid' => 'user-123', + 'tags' => ['tag1', 'tag2'], + 'project_id' => 321, + ], + [ + 'id' => 8, + 'endpoint' => 'https://example.com/push/abC1dEf', + 'uid' => 'user-456', + 'tags' => ['tag3'], + 'project_id' => 321, + ], + ], + 'headers' => [], + 'raw_body' => null, + ]); + + Pushpad::setHttpClient($httpClient); + + $subscriptions = Subscription::findAll(null, ['page' => 2]); + + $this->assertCount(2, $subscriptions); + $this->assertSame('user-123', $subscriptions[0]->uid); + $this->assertSame(['tag3'], $subscriptions[1]->tags); + } + + public function testCountSubscriptionsUsesHeader(): void + { + $httpClient = $this->createMock(HttpClient::class); + $httpClient + ->expects($this->once()) + ->method('request') + ->with('GET', '/projects/321/subscriptions', ['query' => []]) + ->willReturn([ + 'status' => 200, + 'body' => [], + 'headers' => ['x-total-count' => ['42']], + 'raw_body' => null, + ]); + + Pushpad::setHttpClient($httpClient); + + $this->assertSame(42, Subscription::count()); + } + + public function testFindReturnsSubscription(): void + { + $httpClient = $this->createMock(HttpClient::class); + $httpClient + ->expects($this->once()) + ->method('request') + ->with('GET', '/projects/321/subscriptions/7', []) + ->willReturn([ + 'status' => 200, + 'body' => [ + 'id' => 7, + 'endpoint' => 'https://example.com/push/f7Q1Eyf', + 'uid' => 'user-123', + 'tags' => ['tag1'], + ], + 'headers' => [], + 'raw_body' => null, + ]); + + Pushpad::setHttpClient($httpClient); + + $subscription = Subscription::find(7); + + $this->assertSame('user-123', $subscription->uid); + $this->assertSame(321, $subscription->project_id); + } + + public function testCreateSubscriptionSendsPayload(): void + { + $payload = [ + 'endpoint' => 'https://example.com/push/newEndpoint', + 'p256dh' => 'BCQVDTlYWdl05lal3lG5SKr3', + 'auth' => 'cdKMlhgVeSPz', + 'uid' => 'user-789', + 'tags' => ['vip', 'beta'], + ]; + + $httpClient = $this->createMock(HttpClient::class); + $httpClient + ->expects($this->once()) + ->method('request') + ->with( + 'POST', + '/projects/321/subscriptions', + $this->callback(function (array $options) use ($payload): bool { + $this->assertSame($payload, $options['json']); + return true; + }) + ) + ->willReturn([ + 'status' => 201, + 'body' => $payload + ['id' => 99, 'project_id' => 321], + 'headers' => [], + 'raw_body' => null, + ]); + + Pushpad::setHttpClient($httpClient); + + $subscription = Subscription::create($payload); + + $this->assertSame(99, $subscription->id); + $this->assertSame('user-789', $subscription->uid); + } + + public function testCreateSubscriptionRejectsReadOnlyAttribute(): void + { + $payload = [ + 'endpoint' => 'https://example.com/push/newEndpoint', + 'p256dh' => 'BCQVDTlYWdl05lal3lG5SKr3', + 'auth' => 'cdKMlhgVeSPz', + 'uid' => 'user-789', + 'tags' => ['vip', 'beta'], + 'created_at' => '2025-09-14T10:30:00.123Z', + ]; + + $this->expectException(InvalidArgumentException::class); + Subscription::create($payload); + } + + public function testUpdateSubscriptionSendsPayload(): void + { + $httpClient = $this->createMock(HttpClient::class); + $httpClient + ->expects($this->once()) + ->method('request') + ->with('PATCH', '/projects/321/subscriptions/7', ['json' => ['uid' => 'user-updated']]) + ->willReturn([ + 'status' => 200, + 'body' => [ + 'id' => 7, + 'uid' => 'user-updated', + 'endpoint' => 'https://example.com/push/f7Q1Eyf', + ], + 'headers' => [], + 'raw_body' => null, + ]); + + Pushpad::setHttpClient($httpClient); + + $subscription = new Subscription([ + 'id' => 7, + 'project_id' => 321, + 'uid' => 'user-123', + ]); + + $subscription->update(['uid' => 'user-updated']); + + $this->assertSame('user-updated', $subscription->uid); + } + + public function testUpdateSubscriptionRejectsImmutableAttributes(): void + { + $subscription = new Subscription([ + 'id' => 7, + 'project_id' => 321, + 'uid' => 'user-123', + ]); + + $this->expectException(InvalidArgumentException::class); + $subscription->update(['endpoint' => 'https://example.com/push/new']); + } + + public function testDeleteSubscription(): void + { + $httpClient = $this->createMock(HttpClient::class); + $httpClient + ->expects($this->once()) + ->method('request') + ->with('DELETE', '/projects/321/subscriptions/7', []) + ->willReturn([ + 'status' => 204, + 'body' => null, + 'headers' => [], + 'raw_body' => null, + ]); + + Pushpad::setHttpClient($httpClient); + + $subscription = new Subscription([ + 'id' => 7, + 'project_id' => 321, + ]); + + $subscription->delete(); + } +} From f8020f0b678f83ec4f8c615136e3a18f1488e36f Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Sat, 4 Oct 2025 19:13:37 +0200 Subject: [PATCH 02/18] Return response data from Notification::create instead of a Notification --- lib/Notification.php | 12 +++++++++--- tests/NotificationTest.php | 24 ++++++++---------------- 2 files changed, 17 insertions(+), 19 deletions(-) diff --git a/lib/Notification.php b/lib/Notification.php index 3114658..52761c6 100644 --- a/lib/Notification.php +++ b/lib/Notification.php @@ -71,7 +71,10 @@ public static function find(int $notificationId): self return new self($data); } - public static function create(array $payload, ?int $projectId = null): self + /** + * @return array + */ + public static function create(array $payload, ?int $projectId = null): array { $resolvedProjectId = Pushpad::resolveProjectId($projectId); $response = self::httpPost("/projects/{$resolvedProjectId}/notifications", [ @@ -80,10 +83,13 @@ public static function create(array $payload, ?int $projectId = null): self self::ensureStatus($response, 201); $data = $response['body']; - return new self($data); + return $data; } - public static function send(array $payload, ?int $projectId = null): self + /** + * @return array + */ + public static function send(array $payload, ?int $projectId = null): array { return static::create($payload, $projectId); } diff --git a/tests/NotificationTest.php b/tests/NotificationTest.php index 5813534..a89eb37 100644 --- a/tests/NotificationTest.php +++ b/tests/NotificationTest.php @@ -126,11 +126,7 @@ public function testCreateNotificationSendsPayload(): void 'status' => 201, 'body' => [ 'id' => 200001, - 'project_id' => 123, - 'title' => 'New Feature', - 'body' => 'Try our new feature today!', - 'target_url' => 'https://example.com/new-feature', - 'created_at' => '2025-09-16T09:00:00.000Z', + 'scheduled' => 5, ], 'headers' => [], 'raw_body' => null, @@ -138,10 +134,10 @@ public function testCreateNotificationSendsPayload(): void Pushpad::setHttpClient($httpClient); - $notification = Notification::create($payload); + $response = Notification::create($payload); - $this->assertSame(200001, $notification->id); - $this->assertSame('New Feature', $notification->title); + $this->assertSame(200001, $response['id']); + $this->assertSame(5, $response['scheduled']); } public function testSendNotificationUsesCreate(): void @@ -170,11 +166,7 @@ public function testSendNotificationUsesCreate(): void 'status' => 201, 'body' => [ 'id' => 210000, - 'project_id' => 123, - 'title' => 'Weekly Update', - 'body' => 'A recap of the week.', - 'target_url' => 'https://example.com/update', - 'created_at' => '2025-09-17T08:00:00.000Z', + 'scheduled' => 1000, ], 'headers' => [], 'raw_body' => null, @@ -182,10 +174,10 @@ public function testSendNotificationUsesCreate(): void Pushpad::setHttpClient($httpClient); - $notification = Notification::send($payload); + $response = Notification::send($payload); - $this->assertSame(210000, $notification->id); - $this->assertSame('Weekly Update', $notification->title); + $this->assertSame(210000, $response['id']); + $this->assertSame(1000, $response['scheduled']); } public function testCancelNotification(): void From 241be19cd6ef882413f8cb18e18740ae166eae55 Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Sat, 4 Oct 2025 20:57:24 +0200 Subject: [PATCH 03/18] Add .phpunit.result.cache to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 4fbb073..c84ab0c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /vendor/ /composer.lock +/.phpunit.result.cache From 6c083b69e216ef73bfd3d0c5c6c85991e92eaeb2 Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Sat, 4 Oct 2025 21:00:48 +0200 Subject: [PATCH 04/18] Don't wrap Notification title, body, etc. inside a notification key --- tests/NotificationTest.php | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/tests/NotificationTest.php b/tests/NotificationTest.php index a89eb37..9b0a923 100644 --- a/tests/NotificationTest.php +++ b/tests/NotificationTest.php @@ -100,12 +100,10 @@ public function testFindReturnsNotification(): void public function testCreateNotificationSendsPayload(): void { $payload = [ - 'notification' => [ - 'title' => 'New Feature', - 'body' => 'Try our new feature today!', - 'target_url' => 'https://example.com/new-feature', - 'icon_url' => 'https://example.com/icon.png', - ], + 'title' => 'New Feature', + 'body' => 'Try our new feature today!', + 'target_url' => 'https://example.com/new-feature', + 'icon_url' => 'https://example.com/icon.png', 'uids' => ['user1', 'user2'], 'tags' => ['beta-testers'], ]; @@ -143,11 +141,9 @@ public function testCreateNotificationSendsPayload(): void public function testSendNotificationUsesCreate(): void { $payload = [ - 'notification' => [ - 'title' => 'Weekly Update', - 'body' => 'A recap of the week.', - 'target_url' => 'https://example.com/update', - ], + 'title' => 'Weekly Update', + 'body' => 'A recap of the week.', + 'target_url' => 'https://example.com/update', ]; $httpClient = $this->createMock(HttpClient::class); From 887adf41fc3e66d005153102af72ffbc0d918a8c Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Sat, 4 Oct 2025 21:08:52 +0200 Subject: [PATCH 05/18] Add filterForCreatePayload to Notification::create --- lib/Notification.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Notification.php b/lib/Notification.php index 52761c6..317a71b 100644 --- a/lib/Notification.php +++ b/lib/Notification.php @@ -78,7 +78,7 @@ public static function create(array $payload, ?int $projectId = null): array { $resolvedProjectId = Pushpad::resolveProjectId($projectId); $response = self::httpPost("/projects/{$resolvedProjectId}/notifications", [ - 'json' => $payload, + 'json' => self::filterForCreatePayload($payload), ]); self::ensureStatus($response, 201); $data = $response['body']; From 3b1e2ab555d9d9c79fc1b1376041f78563011053 Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Sat, 4 Oct 2025 21:21:43 +0200 Subject: [PATCH 06/18] Fix PHPUnit deprecation warning --- tests/ResourceTest.php | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/ResourceTest.php b/tests/ResourceTest.php index 704aadd..e866ef5 100644 --- a/tests/ResourceTest.php +++ b/tests/ResourceTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Pushpad\HttpClient; use Pushpad\Pushpad; @@ -178,9 +179,7 @@ public function testConstructorKeepsAttributesWhenNoListDefined(): void ], $resource->toArray()); } - /** - * @dataProvider httpMethodProvider - */ + #[DataProvider('httpMethodProvider')] public function testHttpHelpersProxyRequestsToHttpClient(string $method, string $wrapper): void { Pushpad::$auth_token = 'token'; From ecd1ef9f8ffffbe7b8dc42dec146ec76ebe2e8e9 Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Mon, 6 Oct 2025 13:49:44 +0200 Subject: [PATCH 07/18] Add custom exceptions --- lib/Exception/ApiException.php | 121 +++++++++++++++++++++++ lib/Exception/ConfigurationException.php | 13 +++ lib/Exception/NetworkException.php | 12 +++ lib/Exception/PushpadException.php | 15 +++ lib/HttpClient.php | 6 +- lib/Pushpad.php | 8 +- lib/Resource.php | 10 +- tests/PushpadTest.php | 25 +++++ tests/ResourceTest.php | 24 ++++- 9 files changed, 221 insertions(+), 13 deletions(-) create mode 100644 lib/Exception/ApiException.php create mode 100644 lib/Exception/ConfigurationException.php create mode 100644 lib/Exception/NetworkException.php create mode 100644 lib/Exception/PushpadException.php diff --git a/lib/Exception/ApiException.php b/lib/Exception/ApiException.php new file mode 100644 index 0000000..b7fafb4 --- /dev/null +++ b/lib/Exception/ApiException.php @@ -0,0 +1,121 @@ +>|null + */ + private ?array $responseHeaders; + + private ?string $rawBody; + + /** + * @param mixed $responseBody + * @param array>|null $responseHeaders + */ + public function __construct( + string $message, + int $statusCode, + $responseBody = null, + ?array $responseHeaders = null, + ?string $rawBody = null + ) { + parent::__construct($message, $statusCode); + + $this->statusCode = $statusCode; + $this->responseBody = $responseBody; + $this->responseHeaders = $responseHeaders; + $this->rawBody = $rawBody; + } + + public function getStatusCode(): int + { + return $this->statusCode; + } + + /** + * @return mixed + */ + public function getResponseBody() + { + return $this->responseBody; + } + + /** + * @return array>|null + */ + public function getResponseHeaders(): ?array + { + return $this->responseHeaders; + } + + public function getRawBody(): ?string + { + return $this->rawBody; + } + + /** + * @param array{status?:int, body?:mixed, headers?:array>, raw_body?:?string} $response + */ + public static function fromResponse(array $response, int $expectedStatusCode): self + { + $status = isset($response['status']) ? (int) $response['status'] : 0; + $body = $response['body'] ?? null; + $headers = $response['headers'] ?? null; + $rawBody = $response['raw_body'] ?? null; + + $message = self::buildMessage($status, $expectedStatusCode, $body); + + return new self($message, $status, $body, $headers, $rawBody); + } + + /** + * @param mixed $body + */ + private static function buildMessage(int $status, int $expectedStatusCode, $body): string + { + $baseMessage = sprintf('Unexpected status code %d (expected %d).', $status, $expectedStatusCode); + + $details = ''; + + if (is_array($body)) { + foreach (['error_description', 'error', 'message'] as $key) { + if (isset($body[$key]) && is_scalar($body[$key])) { + $details = (string) $body[$key]; + break; + } + } + + if ($details === '' && isset($body['errors'])) { + $encoded = json_encode($body['errors']); + $details = $encoded !== false ? $encoded : ''; + } + } elseif (is_scalar($body) && $body !== '') { + $details = (string) $body; + } + + if ($details === '' && $body !== null) { + $encoded = json_encode($body); + $details = $encoded !== false ? $encoded : ''; + } + + if ($details === '' || $details === 'null') { + return $baseMessage; + } + + return $baseMessage . ' ' . $details; + } +} + diff --git a/lib/Exception/ConfigurationException.php b/lib/Exception/ConfigurationException.php new file mode 100644 index 0000000..c987d36 --- /dev/null +++ b/lib/Exception/ConfigurationException.php @@ -0,0 +1,13 @@ +assertEquals($actual, $expected); } + + public function testSignatureRequiresAuthToken(): void + { + Pushpad::$auth_token = null; + + $this->expectException(ConfigurationException::class); + Pushpad::signature_for('user123'); + } + + public function testHttpRequiresAuthToken(): void + { + Pushpad::$auth_token = null; + + $this->expectException(ConfigurationException::class); + Pushpad::http(); + } + + public function testResolveProjectIdRequiresConfiguredValue(): void + { + Pushpad::$project_id = null; + + $this->expectException(ConfigurationException::class); + Pushpad::resolveProjectId(null); + } } diff --git a/tests/ResourceTest.php b/tests/ResourceTest.php index e866ef5..8ee59be 100644 --- a/tests/ResourceTest.php +++ b/tests/ResourceTest.php @@ -4,6 +4,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use Pushpad\Exception\ApiException; use Pushpad\HttpClient; use Pushpad\Pushpad; use Pushpad\Resource; @@ -165,10 +166,31 @@ public function testEnsureStatusAcceptsExpectedCode(): void public function testEnsureStatusThrowsForUnexpectedCode(): void { - $this->expectException(\UnexpectedValueException::class); + $this->expectException(ApiException::class); DummyResource::exposeEnsureStatus(['status' => 500], 204); } + public function testEnsureStatusExceptionCarriesResponseDetails(): void + { + $response = [ + 'status' => 422, + 'body' => ['message' => 'Invalid input'], + 'headers' => ['content-type' => ['application/json']], + 'raw_body' => '{"message":"Invalid input"}', + ]; + + try { + DummyResource::exposeEnsureStatus($response, 204); + $this->fail('ApiException was not thrown.'); + } catch (ApiException $exception) { + $this->assertSame(422, $exception->getStatusCode()); + $this->assertSame($response['body'], $exception->getResponseBody()); + $this->assertSame($response['headers'], $exception->getResponseHeaders()); + $this->assertSame($response['raw_body'], $exception->getRawBody()); + $this->assertStringContainsString('Invalid input', $exception->getMessage()); + } + } + public function testConstructorKeepsAttributesWhenNoListDefined(): void { $resource = new LooseResource(['foo' => 'bar', 'other' => 'value']); From d46a4949a14254786f27e2b336d883338e396f76 Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Mon, 6 Oct 2025 13:59:23 +0200 Subject: [PATCH 08/18] Require exceptions in init.php --- init.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/init.php b/init.php index 7e3aaab..eb9fb3c 100644 --- a/init.php +++ b/init.php @@ -1,5 +1,9 @@ Date: Mon, 6 Oct 2025 14:36:30 +0200 Subject: [PATCH 09/18] Remove $expectedStatusCode from ApiException --- lib/Exception/ApiException.php | 9 ++++----- lib/Resource.php | 2 +- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/lib/Exception/ApiException.php b/lib/Exception/ApiException.php index b7fafb4..b70432d 100644 --- a/lib/Exception/ApiException.php +++ b/lib/Exception/ApiException.php @@ -69,14 +69,14 @@ public function getRawBody(): ?string /** * @param array{status?:int, body?:mixed, headers?:array>, raw_body?:?string} $response */ - public static function fromResponse(array $response, int $expectedStatusCode): self + public static function fromResponse(array $response): self { $status = isset($response['status']) ? (int) $response['status'] : 0; $body = $response['body'] ?? null; $headers = $response['headers'] ?? null; $rawBody = $response['raw_body'] ?? null; - $message = self::buildMessage($status, $expectedStatusCode, $body); + $message = self::buildMessage($status, $body); return new self($message, $status, $body, $headers, $rawBody); } @@ -84,9 +84,9 @@ public static function fromResponse(array $response, int $expectedStatusCode): s /** * @param mixed $body */ - private static function buildMessage(int $status, int $expectedStatusCode, $body): string + private static function buildMessage(int $status, $body): string { - $baseMessage = sprintf('Unexpected status code %d (expected %d).', $status, $expectedStatusCode); + $baseMessage = sprintf('Pushpad API responded with status %d.', $status); $details = ''; @@ -118,4 +118,3 @@ private static function buildMessage(int $status, int $expectedStatusCode, $body return $baseMessage . ' ' . $details; } } - diff --git a/lib/Resource.php b/lib/Resource.php index f02f005..bf0a59a 100644 --- a/lib/Resource.php +++ b/lib/Resource.php @@ -107,7 +107,7 @@ protected static function ensureStatus(array $response, int $expectedStatusCode) { $status = $response['status'] ?? 0; if ($status !== $expectedStatusCode) { - throw ApiException::fromResponse($response, $expectedStatusCode); + throw ApiException::fromResponse($response); } } From 580e26410a00c5eb8b00801a4c342a50a57efb47 Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Mon, 6 Oct 2025 15:46:52 +0200 Subject: [PATCH 10/18] Optional argument projectId should be last --- lib/Notification.php | 2 +- lib/Subscription.php | 4 ++-- tests/NotificationTest.php | 2 +- tests/SubscriptionTest.php | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/Notification.php b/lib/Notification.php index 317a71b..4042891 100644 --- a/lib/Notification.php +++ b/lib/Notification.php @@ -49,7 +49,7 @@ class Notification extends Resource /** * @return array */ - public static function findAll(?int $projectId = null, array $query = []): array + public static function findAll(array $query = [], ?int $projectId = null): array { $resolvedProjectId = Pushpad::resolveProjectId($projectId); $response = self::httpGet("/projects/{$resolvedProjectId}/notifications", [ diff --git a/lib/Subscription.php b/lib/Subscription.php index 11be6e2..0680cc2 100644 --- a/lib/Subscription.php +++ b/lib/Subscription.php @@ -35,7 +35,7 @@ class Subscription extends Resource /** * @return array */ - public static function findAll(?int $projectId = null, array $query = []): array + public static function findAll(array $query = [], ?int $projectId = null): array { $resolvedProjectId = Pushpad::resolveProjectId($projectId); $response = self::httpGet("/projects/{$resolvedProjectId}/subscriptions", [ @@ -51,7 +51,7 @@ public static function findAll(?int $projectId = null, array $query = []): array ); } - public static function count(?int $projectId = null, array $query = []): int + public static function count(array $query = [], ?int $projectId = null): int { $resolvedProjectId = Pushpad::resolveProjectId($projectId); $response = self::httpGet("/projects/{$resolvedProjectId}/subscriptions", [ diff --git a/tests/NotificationTest.php b/tests/NotificationTest.php index 9b0a923..86f1b6a 100644 --- a/tests/NotificationTest.php +++ b/tests/NotificationTest.php @@ -59,7 +59,7 @@ public function testFindAllReturnsNotifications(): void Pushpad::setHttpClient($httpClient); - $notifications = Notification::findAll(null, ['page' => 1]); + $notifications = Notification::findAll(['page' => 1]); $this->assertCount(2, $notifications); $this->assertSame('Black Friday Deals', $notifications[0]->title); diff --git a/tests/SubscriptionTest.php b/tests/SubscriptionTest.php index c524ef5..dac3d27 100644 --- a/tests/SubscriptionTest.php +++ b/tests/SubscriptionTest.php @@ -53,7 +53,7 @@ public function testFindAllReturnsSubscriptions(): void Pushpad::setHttpClient($httpClient); - $subscriptions = Subscription::findAll(null, ['page' => 2]); + $subscriptions = Subscription::findAll(['page' => 2]); $this->assertCount(2, $subscriptions); $this->assertSame('user-123', $subscriptions[0]->uid); From 358c6e36085e50ed38c16cc8078ece16d270c97b Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Mon, 6 Oct 2025 16:23:46 +0200 Subject: [PATCH 11/18] Add PHPDoc array-shapes for $query --- lib/Notification.php | 1 + lib/Subscription.php | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/lib/Notification.php b/lib/Notification.php index 4042891..3cb4429 100644 --- a/lib/Notification.php +++ b/lib/Notification.php @@ -47,6 +47,7 @@ class Notification extends Resource /** + * @param array{page?: int} $query * @return array */ public static function findAll(array $query = [], ?int $projectId = null): array diff --git a/lib/Subscription.php b/lib/Subscription.php index 0680cc2..2e555e1 100644 --- a/lib/Subscription.php +++ b/lib/Subscription.php @@ -33,6 +33,12 @@ class Subscription extends Resource /** + * @param array{ + * page?: int, + * per_page?: int, + * uids?: list, + * tags?: list + * } $query * @return array */ public static function findAll(array $query = [], ?int $projectId = null): array @@ -51,6 +57,14 @@ public static function findAll(array $query = [], ?int $projectId = null): array ); } + /** + * @param array{ + * page?: int, + * per_page?: int, + * uids?: list, + * tags?: list + * } $query + */ public static function count(array $query = [], ?int $projectId = null): int { $resolvedProjectId = Pushpad::resolveProjectId($projectId); From 41469017a3e88925a472392b33fde07360e910a6 Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Mon, 6 Oct 2025 17:41:47 +0200 Subject: [PATCH 12/18] Add and improve PHPDoc --- lib/HttpClient.php | 30 +++++++++++++++++++ lib/Notification.php | 36 +++++++++++++++++++++-- lib/Project.php | 40 +++++++++++++++++++++++-- lib/Pushpad.php | 49 +++++++++++++++++++++++++++++++ lib/Resource.php | 70 ++++++++++++++++++++++++++++++++++++++++---- lib/Sender.php | 40 +++++++++++++++++++++++-- lib/Subscription.php | 54 ++++++++++++++++++++++++++++++---- 7 files changed, 300 insertions(+), 19 deletions(-) diff --git a/lib/HttpClient.php b/lib/HttpClient.php index f90b973..0ea16fb 100644 --- a/lib/HttpClient.php +++ b/lib/HttpClient.php @@ -6,6 +6,9 @@ use Pushpad\Exception\NetworkException; +/** + * Thin wrapper around cURL tailored to the Pushpad API conventions. + */ class HttpClient { private string $baseUrl; @@ -13,6 +16,14 @@ class HttpClient private int $timeout; private string $userAgent; + /** + * @param string $authToken API token granted by Pushpad. + * @param string $baseUrl Base endpoint for the REST API. + * @param int $timeout Default timeout in seconds for requests. + * @param string|null $userAgent Forces a custom User-Agent header when provided. + * + * @throws \InvalidArgumentException When the authentication token is empty. + */ public function __construct(string $authToken, string $baseUrl = 'https://pushpad.xyz/api/v1', int $timeout = 30, ?string $userAgent = null) { if ($authToken === '') { @@ -26,8 +37,13 @@ public function __construct(string $authToken, string $baseUrl = 'https://pushpa } /** + * Executes an HTTP request against the Pushpad API. + * * @param array{query?:array, json?:mixed, body?:string, headers?:array, timeout?:int} $options * @return array{status:int, body:mixed, headers:array>, raw_body:?string} + * + * @throws NetworkException When the underlying cURL call fails. + * @throws \RuntimeException When encoding the JSON payload fails. */ public function request(string $method, string $path, array $options = []): array { @@ -98,6 +114,9 @@ public function request(string $method, string $path, array $options = []): arra ]; } + /** + * @return list + */ private function defaultHeaders(): array { return [ @@ -106,6 +125,11 @@ private function defaultHeaders(): array ]; } + /** + * Creates an absolute URL including any query string parameters. + * + * @param array $query + */ private function buildUrl(string $path, array $query): string { $url = $this->baseUrl . '/' . ltrim($path, '/'); @@ -121,6 +145,7 @@ private function buildUrl(string $path, array $query): string /** * @param array $query + * @return string */ private function buildQueryString(array $query): string { @@ -146,6 +171,11 @@ private function buildQueryString(array $query): string return implode('&', $parts); } + /** + * Decodes the JSON body when possible, returning the raw string otherwise. + * + * @return mixed + */ private function decode(string $rawBody) { $trimmed = trim($rawBody); diff --git a/lib/Notification.php b/lib/Notification.php index 3cb4429..ecff4be 100644 --- a/lib/Notification.php +++ b/lib/Notification.php @@ -4,6 +4,9 @@ namespace Pushpad; +/** + * Represents a push notification sent with Pushpad. + */ class Notification extends Resource { protected const ATTRIBUTES = [ @@ -44,11 +47,13 @@ class Notification extends Resource 'scheduled', 'cancelled', ]; - - /** + * Fetches notifications, with pagination. + * * @param array{page?: int} $query - * @return array + * @return list + * + * @throws \Pushpad\Exception\ApiException When the API response has an unexpected status. */ public static function findAll(array $query = [], ?int $projectId = null): array { @@ -63,6 +68,11 @@ public static function findAll(array $query = [], ?int $projectId = null): array return array_map(fn (array $item) => new self($item), $items); } + /** + * Retrieves a single notification by its identifier. + * + * @throws \Pushpad\Exception\ApiException When the API response has an unexpected status. + */ public static function find(int $notificationId): self { $response = self::httpGet("/notifications/{$notificationId}"); @@ -73,7 +83,12 @@ public static function find(int $notificationId): self } /** + * Creates and sends a new notification. + * + * @param array $payload * @return array + * + * @throws \Pushpad\Exception\ApiException When the API response has an unexpected status. */ public static function create(array $payload, ?int $projectId = null): array { @@ -88,13 +103,23 @@ public static function create(array $payload, ?int $projectId = null): array } /** + * Creates and sends a new notification. + * + * @param array $payload * @return array + * + * @throws \Pushpad\Exception\ApiException When the API response has an unexpected status. */ public static function send(array $payload, ?int $projectId = null): array { return static::create($payload, $projectId); } + /** + * Cancels the scheduled delivery of the notification when possible. + * + * @throws \Pushpad\Exception\ApiException When the API response has an unexpected status. + */ public function cancel(): void { $response = self::httpDelete("/notifications/{$this->requireId()}/cancel"); @@ -102,6 +127,11 @@ public function cancel(): void $this->attributes['cancelled'] = true; } + /** + * Refreshes the resource with the latest state from the API. + * + * @throws \Pushpad\Exception\ApiException When the API response has an unexpected status. + */ public function refresh(): self { $response = self::httpGet("/notifications/{$this->requireId()}"); diff --git a/lib/Project.php b/lib/Project.php index 2c06a6c..9dd5b2b 100644 --- a/lib/Project.php +++ b/lib/Project.php @@ -4,6 +4,9 @@ namespace Pushpad; +/** + * Models a Pushpad project which groups notifications and subscriptions. + */ class Project extends Resource { protected const ATTRIBUTES = [ @@ -27,10 +30,12 @@ class Project extends Resource protected const IMMUTABLE_ATTRIBUTES = [ 'sender_id', ]; - - /** - * @return array + * Lists all projects available to the configured account. + * + * @return list + * + * @throws \Pushpad\Exception\ApiException When the API response has an unexpected status. */ public static function findAll(): array { @@ -41,6 +46,11 @@ public static function findAll(): array return array_map(fn (array $item) => new self($item), $items); } + /** + * Fetches a single project by its identifier. + * + * @throws \Pushpad\Exception\ApiException When the API response has an unexpected status. + */ public static function find(int $projectId): self { $response = self::httpGet("/projects/{$projectId}"); @@ -50,6 +60,13 @@ public static function find(int $projectId): self return new self($data); } + /** + * Creates a new project. + * + * @param array $payload + * + * @throws \Pushpad\Exception\ApiException When the API response has an unexpected status. + */ public static function create(array $payload): self { $response = self::httpPost('/projects', [ @@ -61,6 +78,11 @@ public static function create(array $payload): self return new self($data); } + /** + * Refreshes the local project attributes with the API state. + * + * @throws \Pushpad\Exception\ApiException When the API response has an unexpected status. + */ public function refresh(): self { $response = self::httpGet("/projects/{$this->requireId()}"); @@ -70,6 +92,13 @@ public function refresh(): self return $this; } + /** + * Updates the project with the provided attributes. + * + * @param array $payload + * + * @throws \Pushpad\Exception\ApiException When the API response has an unexpected status. + */ public function update(array $payload): self { $response = self::httpPatch("/projects/{$this->requireId()}", [ @@ -81,6 +110,11 @@ public function update(array $payload): self return $this; } + /** + * Deletes the project. + * + * @throws \Pushpad\Exception\ApiException When the API response has an unexpected status. + */ public function delete(): void { $response = self::httpDelete("/projects/{$this->requireId()}"); diff --git a/lib/Pushpad.php b/lib/Pushpad.php index 2358670..8181690 100644 --- a/lib/Pushpad.php +++ b/lib/Pushpad.php @@ -6,14 +6,42 @@ use Pushpad\Exception\ConfigurationException; +/** + * Static facade to configure the SDK and provide shared helpers. + */ class Pushpad { + /** + * API token used to authenticate every request performed by the SDK. + */ public static ?string $auth_token = null; + + /** + * Default project identifier used when a project id is not passed explicitly. + */ public static ?int $project_id = null; + + /** + * Base URL for the Pushpad REST API. + */ public static string $base_url = 'https://pushpad.xyz/api/v1'; + + /** + * Default request timeout in seconds. + */ public static int $timeout = 30; + + /** @internal */ private static ?HttpClient $httpClient = null; + /** + * Computes the HMAC signature that can be used to generate signed data. + * + * @param string $data + * @return string + * + * @throws ConfigurationException When the authentication token has not been configured. + */ public static function signature_for(string $data): string { if (!isset(self::$auth_token)) { @@ -23,11 +51,24 @@ public static function signature_for(string $data): string return hash_hmac('sha256', $data, self::$auth_token); } + /** + * Overrides the HTTP client instance used by the SDK, mainly for testing purposes. + * + * @param HttpClient|null $httpClient + * @return void + */ public static function setHttpClient(?HttpClient $httpClient): void { self::$httpClient = $httpClient; } + /** + * Returns the configured HTTP client, instantiating a default one when needed. + * + * @return HttpClient + * + * @throws ConfigurationException When the authentication token has not been configured. + */ public static function http(): HttpClient { if (!isset(self::$auth_token) || self::$auth_token === '') { @@ -47,6 +88,14 @@ public static function http(): HttpClient return self::$httpClient; } + /** + * Determines which project identifier should be used for an API call. + * + * @param int|null $projectId + * @return int + * + * @throws ConfigurationException When neither the argument nor the global default is set. + */ public static function resolveProjectId(?int $projectId): int { if ($projectId !== null) { diff --git a/lib/Resource.php b/lib/Resource.php index bf0a59a..276169d 100644 --- a/lib/Resource.php +++ b/lib/Resource.php @@ -29,12 +29,22 @@ public function toArray(): array { return $this->attributes; } - + + /** + * @return array + */ public function jsonSerialize(): mixed { return $this->attributes; } + /** + * Dynamically reads an attribute exposed by the resource. + * + * @return mixed + * + * @throws \InvalidArgumentException When the attribute is not part of the resource definition. + */ public function __get(string $name) { if (!in_array($name, static::attributes(), true)) { @@ -44,16 +54,29 @@ public function __get(string $name) return $this->attributes[$name] ?? null; } + /** + * Checks whether an attribute exists and has been hydrated in the resource instance. + */ public function __isset(string $name): bool { return in_array($name, static::attributes(), true) && array_key_exists($name, $this->attributes); } + /** + * @param array $attributes + */ protected function setAttributes(array $attributes): void { $this->attributes = $this->filterStoredAttributes($attributes); } + /** + * Returns the numeric identifier of the resource. + * + * @return int + * + * @throws \LogicException When the resource does not have an id. + */ protected function requireId(): int { if (!isset($this->attributes['id'])) { @@ -63,13 +86,22 @@ protected function requireId(): int return (int) $this->attributes['id']; } + /** + * Provides the HTTP client that should be used for API calls. + * + * @throws \Pushpad\Exception\ConfigurationException When the SDK is not properly configured yet. + */ protected static function http(): HttpClient { return Pushpad::http(); } /** + * @param array $options * @return array{status:int, body:mixed, headers:array>|null, raw_body:?string} + * + * @throws \Pushpad\Exception\NetworkException When the HTTP request fails. + * @throws \RuntimeException When encoding the request payload fails. */ protected static function httpGet(string $path, array $options = []): array { @@ -77,7 +109,11 @@ protected static function httpGet(string $path, array $options = []): array } /** + * @param array $options * @return array{status:int, body:mixed, headers:array>|null, raw_body:?string} + * + * @throws \Pushpad\Exception\NetworkException When the HTTP request fails. + * @throws \RuntimeException When encoding the request payload fails. */ protected static function httpPost(string $path, array $options = []): array { @@ -85,7 +121,11 @@ protected static function httpPost(string $path, array $options = []): array } /** + * @param array $options * @return array{status:int, body:mixed, headers:array>|null, raw_body:?string} + * + * @throws \Pushpad\Exception\NetworkException When the HTTP request fails. + * @throws \RuntimeException When encoding the request payload fails. */ protected static function httpPatch(string $path, array $options = []): array { @@ -93,7 +133,11 @@ protected static function httpPatch(string $path, array $options = []): array } /** + * @param array $options * @return array{status:int, body:mixed, headers:array>|null, raw_body:?string} + * + * @throws \Pushpad\Exception\NetworkException When the HTTP request fails. + * @throws \RuntimeException When encoding the request payload fails. */ protected static function httpDelete(string $path, array $options = []): array { @@ -102,6 +146,8 @@ protected static function httpDelete(string $path, array $options = []): array /** * @param array{status:int} $response + * + * @throws ApiException When the status code differs from the expected value. */ protected static function ensureStatus(array $response, int $expectedStatusCode): void { @@ -113,6 +159,9 @@ protected static function ensureStatus(array $response, int $expectedStatusCode) /** * @param array $attributes + * @return array + * + * @throws \InvalidArgumentException When the payload contains unsupported attributes. */ protected static function filterForCreatePayload(array $attributes): array { @@ -126,6 +175,9 @@ protected static function filterForCreatePayload(array $attributes): array /** * @param array $attributes + * @return array + * + * @throws \InvalidArgumentException When the payload contains unsupported attributes. */ protected static function filterForUpdatePayload(array $attributes): array { @@ -140,6 +192,7 @@ protected static function filterForUpdatePayload(array $attributes): array /** * @param array $attributes + * @return array */ private function filterStoredAttributes(array $attributes): array { @@ -148,8 +201,11 @@ private function filterStoredAttributes(array $attributes): array /** * @param array $attributes - * @param array $allowed + * @param list $allowed + * @param bool $rejectUnknown When true an exception is raised for attributes outside of `$allowed`. * @return array + * + * @throws \InvalidArgumentException When `$rejectUnknown` is true and unknown attributes are provided. */ private static function filterToAllowedAttributes(array $attributes, array $allowed, bool $rejectUnknown = false): array { @@ -177,20 +233,24 @@ private static function filterToAllowedAttributes(array $attributes, array $allo } /** - * @param array $attributes - * @param array $keys - * @return array + * @return list */ private static function attributes(): array { return defined('static::ATTRIBUTES') ? static::ATTRIBUTES : []; } + /** + * @return list + */ private static function readOnlyAttributes(): array { return defined('static::READ_ONLY_ATTRIBUTES') ? static::READ_ONLY_ATTRIBUTES : []; } + /** + * @return list + */ private static function immutableAttributes(): array { return defined('static::IMMUTABLE_ATTRIBUTES') ? static::IMMUTABLE_ATTRIBUTES : []; diff --git a/lib/Sender.php b/lib/Sender.php index d9d557f..867a1ff 100644 --- a/lib/Sender.php +++ b/lib/Sender.php @@ -4,6 +4,9 @@ namespace Pushpad; +/** + * Represents a Pushpad sender resource which holds the VAPID credentials. + */ class Sender extends Resource { protected const ATTRIBUTES = [ @@ -23,10 +26,12 @@ class Sender extends Resource 'vapid_private_key', 'vapid_public_key', ]; - - /** - * @return array + * Lists all senders configured in the account. + * + * @return list + * + * @throws \Pushpad\Exception\ApiException When the API response has an unexpected status. */ public static function findAll(): array { @@ -37,6 +42,11 @@ public static function findAll(): array return array_map(fn (array $item) => new self($item), $items); } + /** + * Fetches a sender by its identifier. + * + * @throws \Pushpad\Exception\ApiException When the API response has an unexpected status. + */ public static function find(int $senderId): self { $response = self::httpGet("/senders/{$senderId}"); @@ -46,6 +56,13 @@ public static function find(int $senderId): self return new self($data); } + /** + * Creates a new sender. + * + * @param array $payload + * + * @throws \Pushpad\Exception\ApiException When the API response has an unexpected status. + */ public static function create(array $payload): self { $response = self::httpPost('/senders', [ @@ -57,6 +74,11 @@ public static function create(array $payload): self return new self($data); } + /** + * Refreshes the local representation with the API state. + * + * @throws \Pushpad\Exception\ApiException When the API response has an unexpected status. + */ public function refresh(): self { $response = self::httpGet("/senders/{$this->requireId()}"); @@ -66,6 +88,13 @@ public function refresh(): self return $this; } + /** + * Updates the sender with the provided attributes. + * + * @param array $payload + * + * @throws \Pushpad\Exception\ApiException When the API response has an unexpected status. + */ public function update(array $payload): self { $response = self::httpPatch("/senders/{$this->requireId()}", [ @@ -77,6 +106,11 @@ public function update(array $payload): self return $this; } + /** + * Deletes the sender. + * + * @throws \Pushpad\Exception\ApiException When the API response has an unexpected status. + */ public function delete(): void { $response = self::httpDelete("/senders/{$this->requireId()}"); diff --git a/lib/Subscription.php b/lib/Subscription.php index 2e555e1..0008fc2 100644 --- a/lib/Subscription.php +++ b/lib/Subscription.php @@ -4,6 +4,9 @@ namespace Pushpad; +/** + * Represents a push subscription associated with a Pushpad project. + */ class Subscription extends Resource { protected const ATTRIBUTES = [ @@ -30,16 +33,19 @@ class Subscription extends Resource 'p256dh', 'auth', ]; - - + /** + * Retrieves project subscriptions, with pagination and optional filters. + * * @param array{ * page?: int, * per_page?: int, * uids?: list, * tags?: list * } $query - * @return array + * @return list + * + * @throws \Pushpad\Exception\ApiException When the API response has an unexpected status. */ public static function findAll(array $query = [], ?int $projectId = null): array { @@ -58,12 +64,15 @@ public static function findAll(array $query = [], ?int $projectId = null): array } /** + * Count the subscriptions, with optional filters. + * * @param array{ - * page?: int, - * per_page?: int, * uids?: list, * tags?: list * } $query + * + * @throws \Pushpad\Exception\ApiException When the API response has an unexpected status. + * @throws \UnexpectedValueException When the response does not include the expected header. */ public static function count(array $query = [], ?int $projectId = null): int { @@ -83,6 +92,11 @@ public static function count(array $query = [], ?int $projectId = null): int return (int) $headers[$name][0]; } + /** + * Retrieves a subscription by its identifier. + * + * @throws \Pushpad\Exception\ApiException When the API response has an unexpected status. + */ public static function find(int $subscriptionId, ?int $projectId = null): self { $resolvedProjectId = Pushpad::resolveProjectId($projectId); @@ -93,6 +107,13 @@ public static function find(int $subscriptionId, ?int $projectId = null): self return new self(self::injectProjectId($data, $resolvedProjectId)); } + /** + * Creates a subscription. + * + * @param array $payload + * + * @throws \Pushpad\Exception\ApiException When the API response has an unexpected status. + */ public static function create(array $payload, ?int $projectId = null): self { $resolvedProjectId = Pushpad::resolveProjectId($projectId); @@ -106,6 +127,11 @@ public static function create(array $payload, ?int $projectId = null): self return new self(self::injectProjectId($data, $resolvedProjectId)); } + /** + * Refreshes the resource with the current data returned by the API. + * + * @throws \Pushpad\Exception\ApiException When the API response has an unexpected status. + */ public function refresh(?int $projectId = null): self { $project = $this->determineProjectId($projectId); @@ -116,6 +142,13 @@ public function refresh(?int $projectId = null): self return $this; } + /** + * Updates a subscription. + * + * @param array $payload + * + * @throws \Pushpad\Exception\ApiException When the API response has an unexpected status. + */ public function update(array $payload, ?int $projectId = null): self { $project = $this->determineProjectId($projectId); @@ -129,6 +162,11 @@ public function update(array $payload, ?int $projectId = null): self return $this; } + /** + * Deletes the subscription. + * + * @throws \Pushpad\Exception\ApiException When the API response has an unexpected status. + */ public function delete(?int $projectId = null): void { $project = $this->determineProjectId($projectId); @@ -136,6 +174,11 @@ public function delete(?int $projectId = null): void self::ensureStatus($response, 204); } + /** + * Determines which project id should be used for an instance method call. + * + * @throws \Pushpad\Exception\ConfigurationException When no project id can be resolved. + */ private function determineProjectId(?int $projectId = null): int { if ($projectId !== null) { @@ -151,6 +194,7 @@ private function determineProjectId(?int $projectId = null): int /** * @param array $data + * @param int $projectId * @return array */ private static function injectProjectId(array $data, int $projectId): array From 8b236daa7192677a99a0cb77edb12a41fc2ba540 Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Mon, 6 Oct 2025 21:21:41 +0200 Subject: [PATCH 13/18] Always use camelCase --- README.md | 10 +++++----- lib/Pushpad.php | 28 ++++++++++++++-------------- tests/NotificationTest.php | 8 ++++---- tests/ProjectTest.php | 4 ++-- tests/PushpadTest.php | 14 +++++++------- tests/ResourceTest.php | 4 ++-- tests/SenderTest.php | 4 ++-- tests/SubscriptionTest.php | 8 ++++---- 8 files changed, 40 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index e339b05..534f513 100644 --- a/README.md +++ b/README.md @@ -45,12 +45,12 @@ First you need to sign up to Pushpad and create a project there. Then set your authentication credentials: ```php -Pushpad\Pushpad::$auth_token = '5374d7dfeffa2eb49965624ba7596a09'; -Pushpad\Pushpad::$project_id = 123; # set it here or pass it as a param to methods later +Pushpad\Pushpad::$authToken = '5374d7dfeffa2eb49965624ba7596a09'; +Pushpad\Pushpad::$projectId = 123; # set it here or pass it as a param to methods later ``` -- `auth_token` can be found in the user account settings. -- `project_id` can be found in the project settings. If your application uses multiple projects, you can pass the `project_id` as a param to methods (e.g. `$notification->deliver_to(user_id, array('project_id' => 123))`). +- `authToken` can be found in the user account settings. +- `projectId` can be found in the project settings. If your application uses multiple projects, you can pass the `project_id` as a param to methods (e.g. `$notification->deliver_to(user_id, array('project_id' => 123))`). ## Collecting user subscriptions to push notifications @@ -59,7 +59,7 @@ You can subscribe the users to your notifications using the Javascript SDK, as d If you need to generate the HMAC signature for the `uid` you can use this helper: ```php -Pushpad\Pushpad::signature_for($current_user_id); +Pushpad\Pushpad::signatureFor($current_user_id); ``` ## Sending push notifications diff --git a/lib/Pushpad.php b/lib/Pushpad.php index 8181690..dc0a6a6 100644 --- a/lib/Pushpad.php +++ b/lib/Pushpad.php @@ -14,17 +14,17 @@ class Pushpad /** * API token used to authenticate every request performed by the SDK. */ - public static ?string $auth_token = null; + public static ?string $authToken = null; /** * Default project identifier used when a project id is not passed explicitly. */ - public static ?int $project_id = null; + public static ?int $projectId = null; /** * Base URL for the Pushpad REST API. */ - public static string $base_url = 'https://pushpad.xyz/api/v1'; + public static string $baseUrl = 'https://pushpad.xyz/api/v1'; /** * Default request timeout in seconds. @@ -42,13 +42,13 @@ class Pushpad * * @throws ConfigurationException When the authentication token has not been configured. */ - public static function signature_for(string $data): string + public static function signatureFor(string $data): string { - if (!isset(self::$auth_token)) { - throw new ConfigurationException('Pushpad::$auth_token must be set before calling signature_for().'); + if (!isset(self::$authToken)) { + throw new ConfigurationException('Pushpad::$authToken must be set before calling signatureFor().'); } - return hash_hmac('sha256', $data, self::$auth_token); + return hash_hmac('sha256', $data, self::$authToken); } /** @@ -71,8 +71,8 @@ public static function setHttpClient(?HttpClient $httpClient): void */ public static function http(): HttpClient { - if (!isset(self::$auth_token) || self::$auth_token === '') { - throw new ConfigurationException('Pushpad::$auth_token must be a non-empty string.'); + if (!isset(self::$authToken) || self::$authToken === '') { + throw new ConfigurationException('Pushpad::$authToken must be a non-empty string.'); } if (self::$httpClient instanceof HttpClient) { @@ -80,8 +80,8 @@ public static function http(): HttpClient } self::$httpClient = new HttpClient( - self::$auth_token, - self::$base_url, + self::$authToken, + self::$baseUrl, self::$timeout ); @@ -102,10 +102,10 @@ public static function resolveProjectId(?int $projectId): int return $projectId; } - if (self::$project_id !== null) { - return self::$project_id; + if (self::$projectId !== null) { + return self::$projectId; } - throw new ConfigurationException('Pushpad::$project_id must be configured or provided explicitly.'); + throw new ConfigurationException('Pushpad::$projectId must be configured or provided explicitly.'); } } diff --git a/tests/NotificationTest.php b/tests/NotificationTest.php index 86f1b6a..e1da9ff 100644 --- a/tests/NotificationTest.php +++ b/tests/NotificationTest.php @@ -11,15 +11,15 @@ class NotificationTest extends TestCase { protected function setUp(): void { - Pushpad::$auth_token = 'token'; - Pushpad::$project_id = 123; + Pushpad::$authToken = 'token'; + Pushpad::$projectId = 123; } protected function tearDown(): void { Pushpad::setHttpClient(null); - Pushpad::$auth_token = null; - Pushpad::$project_id = null; + Pushpad::$authToken = null; + Pushpad::$projectId = null; } public function testFindAllReturnsNotifications(): void diff --git a/tests/ProjectTest.php b/tests/ProjectTest.php index 2bbc4d3..54950e5 100644 --- a/tests/ProjectTest.php +++ b/tests/ProjectTest.php @@ -11,13 +11,13 @@ class ProjectTest extends TestCase { protected function setUp(): void { - Pushpad::$auth_token = 'token'; + Pushpad::$authToken = 'token'; } protected function tearDown(): void { Pushpad::setHttpClient(null); - Pushpad::$auth_token = null; + Pushpad::$authToken = null; } public function testFindAllReturnsProjects(): void diff --git a/tests/PushpadTest.php b/tests/PushpadTest.php index 7d61bee..b892e39 100644 --- a/tests/PushpadTest.php +++ b/tests/PushpadTest.php @@ -7,27 +7,27 @@ class PushpadTest extends TestCase { protected function setUp(): void { - Pushpad::$auth_token = '5374d7dfeffa2eb49965624ba7596a09'; - Pushpad::$project_id = 123; + Pushpad::$authToken = '5374d7dfeffa2eb49965624ba7596a09'; + Pushpad::$projectId = 123; } public function testSignature() { - $actual = Pushpad::signature_for('user12345'); + $actual = Pushpad::signatureFor('user12345'); $expected = '6627820dab00a1971f2a6d3ff16a5ad8ba4048a02b2d402820afc61aefd0b69f'; $this->assertEquals($actual, $expected); } public function testSignatureRequiresAuthToken(): void { - Pushpad::$auth_token = null; + Pushpad::$authToken = null; $this->expectException(ConfigurationException::class); - Pushpad::signature_for('user123'); + Pushpad::signatureFor('user123'); } public function testHttpRequiresAuthToken(): void { - Pushpad::$auth_token = null; + Pushpad::$authToken = null; $this->expectException(ConfigurationException::class); Pushpad::http(); @@ -35,7 +35,7 @@ public function testHttpRequiresAuthToken(): void public function testResolveProjectIdRequiresConfiguredValue(): void { - Pushpad::$project_id = null; + Pushpad::$projectId = null; $this->expectException(ConfigurationException::class); Pushpad::resolveProjectId(null); diff --git a/tests/ResourceTest.php b/tests/ResourceTest.php index 8ee59be..8769dbb 100644 --- a/tests/ResourceTest.php +++ b/tests/ResourceTest.php @@ -14,7 +14,7 @@ class ResourceTest extends TestCase protected function tearDown(): void { Pushpad::setHttpClient(null); - Pushpad::$auth_token = null; + Pushpad::$authToken = null; } public function testConstructorFiltersUnknownAttributes(): void @@ -204,7 +204,7 @@ public function testConstructorKeepsAttributesWhenNoListDefined(): void #[DataProvider('httpMethodProvider')] public function testHttpHelpersProxyRequestsToHttpClient(string $method, string $wrapper): void { - Pushpad::$auth_token = 'token'; + Pushpad::$authToken = 'token'; $httpClient = $this->createMock(HttpClient::class); $response = [ 'status' => 200, diff --git a/tests/SenderTest.php b/tests/SenderTest.php index 58638ee..eee7fb1 100644 --- a/tests/SenderTest.php +++ b/tests/SenderTest.php @@ -11,13 +11,13 @@ class SenderTest extends TestCase { protected function setUp(): void { - Pushpad::$auth_token = 'token'; + Pushpad::$authToken = 'token'; } protected function tearDown(): void { Pushpad::setHttpClient(null); - Pushpad::$auth_token = null; + Pushpad::$authToken = null; } public function testFindAllReturnsSenders(): void diff --git a/tests/SubscriptionTest.php b/tests/SubscriptionTest.php index dac3d27..2688e99 100644 --- a/tests/SubscriptionTest.php +++ b/tests/SubscriptionTest.php @@ -11,15 +11,15 @@ class SubscriptionTest extends TestCase { protected function setUp(): void { - Pushpad::$auth_token = 'token'; - Pushpad::$project_id = 321; + Pushpad::$authToken = 'token'; + Pushpad::$projectId = 321; } protected function tearDown(): void { Pushpad::setHttpClient(null); - Pushpad::$auth_token = null; - Pushpad::$project_id = null; + Pushpad::$authToken = null; + Pushpad::$projectId = null; } public function testFindAllReturnsSubscriptions(): void From 5c4748a9d373aed1752a2d2660a137c7e12d93f4 Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Mon, 6 Oct 2025 23:58:06 +0200 Subject: [PATCH 14/18] Update README --- README.md | 329 +++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 239 insertions(+), 90 deletions(-) diff --git a/README.md b/README.md index 534f513..52512ee 100644 --- a/README.md +++ b/README.md @@ -4,161 +4,310 @@ [![Latest Stable Version](https://poser.pugx.org/pushpad/pushpad-php/v)](//packagist.org/packages/pushpad/pushpad-php) [![Total Downloads](https://poser.pugx.org/pushpad/pushpad-php/downloads)](//packagist.org/packages/pushpad/pushpad-php) [![License](https://poser.pugx.org/pushpad/pushpad-php/license)](//packagist.org/packages/pushpad/pushpad-php) - -[Pushpad](https://pushpad.xyz) is a service for sending push notifications from websites and web apps. It uses the **Push API**, which is a standard supported by all major browsers (Chrome, Firefox, Opera, Edge, Safari). -The notifications are delivered in real time even when the users are not on your website and you can target specific users or send bulk notifications. +[Pushpad](https://pushpad.xyz) is a service for sending push notifications from websites and web apps. It uses the **Push API**, which is supported by all major browsers (Chrome, Firefox, Opera, Edge, Safari). + +Notifications are delivered in real time even when the users are not on your website and you can target specific users or send bulk notifications. ## Installation ### Composer -You can install the bindings via [Composer](http://getcomposer.org/). Run the following command: +This package requires PHP 8.0+ with the `ext-curl` and `ext-json` extensions. + +Install the SDK via [Composer](https://getcomposer.org/): ```bash composer require pushpad/pushpad-php ``` -To use the bindings, use Composer's autoload: +Then include Composer's autoloader: ```php -require_once('vendor/autoload.php'); +require_once __DIR__ . '/vendor/autoload.php'; ``` -### Manual Installation +### Manual installation -Download the latest version of this library: +Clone the repository and require the bootstrap file in your project: - $ git clone https://github.com/pushpad/pushpad-php.git - -Then add this line to your application: +```bash +git clone https://github.com/pushpad/pushpad-php.git +``` ```php -require_once('path/to/pushpad-php/init.php'); - +require_once __DIR__ . '/path/to/pushpad-php/init.php'; ``` ## Getting started -First you need to sign up to Pushpad and create a project there. +First sign up to Pushpad and create a project. -Then set your authentication credentials: +Configure the SDK with your credentials before you make any API calls: ```php Pushpad\Pushpad::$authToken = '5374d7dfeffa2eb49965624ba7596a09'; -Pushpad\Pushpad::$projectId = 123; # set it here or pass it as a param to methods later +Pushpad\Pushpad::$projectId = 123; // set a default project (optional) ``` -- `authToken` can be found in the user account settings. -- `projectId` can be found in the project settings. If your application uses multiple projects, you can pass the `project_id` as a param to methods (e.g. `$notification->deliver_to(user_id, array('project_id' => 123))`). +- `authToken` can be created in the account settings. +- `projectId` is shown in the project settings. If you work with multiple projects you can pass a different project id to individual method calls instead of configuring the global default. -## Collecting user subscriptions to push notifications +## Collecting user subscriptions -You can subscribe the users to your notifications using the Javascript SDK, as described in the [getting started guide](https://pushpad.xyz/docs/pushpad_pro_getting_started). +Use the JavaScript SDK to subscribe users to push notifications (see the [getting started guide](https://pushpad.xyz/docs/pushpad_pro_getting_started)). -If you need to generate the HMAC signature for the `uid` you can use this helper: +When you need to sign a `uid`, generate the HMAC signature with: ```php -Pushpad\Pushpad::signatureFor($current_user_id); +$signature = Pushpad\Pushpad::signatureFor((string) $currentUserId); ``` ## Sending push notifications +Use `Pushpad\Notification::create()` (or the `send()` alias) to create and send a notification: + +```php +$response = Pushpad\Notification::create([ + // required content + 'body' => 'Hello world!', + + // optional fields + 'title' => 'Website Name', + 'target_url' => 'https://example.com', + 'icon_url' => 'https://example.com/assets/icon.png', + 'badge_url' => 'https://example.com/assets/badge.png', + 'image_url' => 'https://example.com/assets/image.png', + 'ttl' => 604800, + 'require_interaction' => true, + 'silent' => false, + 'urgent' => false, + 'custom_data' => '123', + 'actions' => [ + [ + 'title' => 'My Button 1', + 'target_url' => 'https://example.com/button-link', + 'icon' => 'https://example.com/assets/button-icon.png', + 'action' => 'myActionName', + ], + ], + 'starred' => true, + 'send_at' => (new DateTimeImmutable('+1 hour'))->format(DATE_ATOM), + 'custom_metrics' => ['examples', 'another_metric'], + + // targeting options + 'uids' => ['user-1', 'user-2'], + 'tags' => ['segment1', 'segment2'], +]); +``` + +- Omit `uids` and `tags` to broadcast to everyone. +- If you set `uids` and some users are not subscribed to notifications, Pushpad ignores them. +- Use boolean expressions inside `tags` for complex segments (e.g. `'zip_code:28865 && !optout:local_events'`). +- Scheduled notifications require an ISO 8601 timestamp in `send_at` (as produced by `DateTimeInterface::format(DATE_ATOM)`). +- You can set default values for most notification fields in the project settings. Refer to the [REST API docs](https://pushpad.xyz/docs/rest_api#notifications_api_docs) for the exhaustive list of options. + +The response includes useful information: + +```php +// Notification ID +$notificationId = $response['id']; + +// Estimated number of devices that will receive the notification +// Not available for notifications that use send_at +$estimatedReach = $response['scheduled']; + +// Available only if you specify some user IDs (uids) in the request: +// it indicates which of those users are subscribed to notifications. +// Not available for notifications that use send_at +$reachedUids = $response['uids']; + +// The time when the notification will be sent. +// Available for notifications that use send_at +$scheduledAt = $response['send_at']; +``` + +## Getting push notification data + +Fetch a single notification and inspect its attributes: + +```php +$notification = Pushpad\Notification::find(42); + +echo $notification->title; // "Foo Bar" +echo $notification->target_url; // "https://example.com" +echo $notification->ttl; // 604800 +echo $notification->created_at; // ISO 8601 string +echo $notification->successfully_sent_count; // 4 +echo $notification->opened_count; // 2 + +// ... and many other attributes +print_r($notification->toArray()); +``` + +List notifications for a project (pagination supported through the `page` query parameter): + +```php +$notifications = Pushpad\Notification::findAll(['page' => 1]); + +foreach ($notifications as $item) { + printf("Notification %d: %s\n", $item->id, $item->title); +} +``` + +Pass the project id as the second argument when you prefer not to rely on the globally configured `Pushpad\Pushpad::$projectId`: + +``` +Pushpad\Notification::findAll([], projectId: 456); +``` + +If you need to refresh a previously loaded notification, call `$notification->refresh()` to hydrate the latest data from the API. + +## Scheduled notifications + +Create a notification that will be sent later: + +```php +Pushpad\Notification::create([ + 'body' => 'This notification will be sent after 60 seconds', + 'send_at' => (new DateTimeImmutable('+60 seconds'))->format(DATE_ATOM), +]); +``` + +Cancel a scheduled notification when it is still pending: + +```php +$notification = Pushpad\Notification::find(5); +$notification->cancel(); +``` + +## Getting subscription count + +Retrieve the number of subscriptions associated with a project, optionally filtered by user IDs or tags: + ```php -$notification = new Pushpad\Notification(array( - # required, the main content of the notification - 'body' => "Hello world!", +$total = Pushpad\Subscription::count(); +$byUser = Pushpad\Subscription::count(['uids' => ['user1']]); +$byTags = Pushpad\Subscription::count(['tags' => ['sports && travel']]); +$combined = Pushpad\Subscription::count(['uids' => ['user1'], 'tags' => ['sports && travel']], 5); +``` + +The second argument lets you override the project id if you did not configure `Pushpad\Pushpad::$projectId` or you need to switch project on the fly. - # optional, the title of the notification (defaults to your project name) - 'title' => "Website Name", +## Getting push subscription data - # optional, open this link on notification click (defaults to your project website) - 'target_url' => "https://example.com", +Fetch subscriptions with optional filters and pagination: - # optional, the icon of the notification (defaults to the project icon) - 'icon_url' => "https://example.com/assets/icon.png", +```php +$subscriptions = Pushpad\Subscription::findAll(['tags' => ['sports'], 'page' => 2]); - # optional, the small icon displayed in the status bar (defaults to the project badge) - 'badge_url' => "https://example.com/assets/badge.png", +foreach ($subscriptions as $subscription) { + echo $subscription->id . PHP_EOL; + // ... +} +``` - # optional, an image to display in the notification content - # see https://pushpad.xyz/docs/sending_images - 'image_url' => "https://example.com/assets/image.png", +Load a specific subscription when you already know its id: - # optional, drop the notification after this number of seconds if a device is offline - 'ttl' => 604800, +```php +$subscription = Pushpad\Subscription::find(123); - # optional, prevent Chrome on desktop from automatically closing the notification after a few seconds - 'require_interaction' => true, +echo $subscription->id; +echo $subscription->endpoint; +echo $subscription->uid; +echo $subscription->tags; +echo $subscription->last_click_at; +echo $subscription->created_at; - # optional, enable this option if you want a mute notification without any sound - 'silent' => false, +// ... and many other attributes +print_r($subscription->toArray()); +``` - # optional, enable this option only for time-sensitive alerts (e.g. incoming phone call) - 'urgent' => false, +## Updating push subscription data - # optional, a string that is passed as an argument to action button callbacks - 'custom_data' => "123", +Although tags and user IDs are usually managed from the JavaScript SDK, you can also update them from server: - # optional, add some action buttons to the notification - # see https://pushpad.xyz/docs/action_buttons - 'actions' => array( - array( - 'title' => "My Button 1", - 'target_url' => "https://example.com/button-link", # optional - 'icon' => "https://example.com/assets/button-icon.png", # optional - 'action' => "myActionName" # optional - ) - ), +```php +$subscriptions = Pushpad\Subscription::findAll(['uids' => ['user1']]); - # optional, bookmark the notification in the Pushpad dashboard (e.g. to highlight manual notifications) - 'starred' => true, +foreach ($subscriptions as $subscription) { + $tags = $subscription->tags ?? []; + $tags[] = 'another_tag'; - # optional, use this option only if you need to create scheduled notifications (max 5 days) - # see https://pushpad.xyz/docs/schedule_notifications - 'send_at' => strtotime('2016-07-25 10:09'), # use a function like strtotime or time that returns a Unix timestamp + $subscription->update([ + 'uid' => 'myuser1', + 'tags' => array_values(array_unique($tags)), + ]); +} +``` - # optional, add the notification to custom categories for stats aggregation - # see https://pushpad.xyz/docs/monitoring - 'custom_metrics' => array('examples', 'another_metric') # up to 3 metrics per notification -)); +## Importing push subscriptions -# deliver to a user -$notification->deliver_to($user_id); +To import existing subscriptions or seed test data use `Pushpad\Subscription::create()`: -# deliver to a group of users -$notification->deliver_to($user_ids); +```php +$subscription = Pushpad\Subscription::create([ + 'endpoint' => 'https://example.com/push/f7Q1Eyf7EyfAb1', + 'p256dh' => 'BCQVDTlYWdl05lal3lG5SKr3VxTrEWpZErbkxWrzknHrIKFwihDoZpc_2sH6Sh08h-CacUYI-H8gW4jH-uMYZQ4=', + 'auth' => 'cdKMlhgVeSPzCXZ3V7FtgQ==', + 'uid' => 'exampleUid', + 'tags' => ['exampleTag1', 'exampleTag2'], +]); +``` -# deliver to some users only if they have a given preference -# e.g. only $users who have a interested in "events" will be reached -$notification->deliver_to($users, ["tags" => ["events"]]); +Typically subscriptions are collected from the browser using the [JavaScript SDK](https://pushpad.xyz/docs/javascript_sdk_reference); server-side creation should be reserved for migrations and special workflows. -# deliver to segments -# e.g. any subscriber that has the tag "segment1" OR "segment2" -$notification->broadcast(["tags" => ["segment1", "segment2"]]); +## Deleting push subscriptions -# you can use boolean expressions -# they can include parentheses and the operators !, &&, || (from highest to lowest precedence) -# https://pushpad.xyz/docs/tags -$notification->broadcast(["tags" => ["zip_code:28865 && !optout:local_events || friend_of:Organizer123"]]); -$notification->deliver_to($users, ["tags" => ["tag1 && tag2", "tag3"]]); # equal to "tag1 && tag2 || tag3" +Delete subscriptions programmatically (use with care, the operation is irreversible): -# deliver to everyone -$notification->broadcast(); +```php +$subscription = Pushpad\Subscription::find(123); +$subscription->delete(); ``` -You can set the default values for most fields in the project settings. See also [the docs](https://pushpad.xyz/docs/rest_api#notifications_api_docs) for more information about notification fields. +## Managing projects + +Projects can also be managed via the API for automation use cases: + +```php +$project = Pushpad\Project::create([ + 'sender_id' => 123, + 'name' => 'My project', + 'website' => 'https://example.com', + 'icon_url' => 'https://example.com/icon.png', + 'badge_url' => 'https://example.com/badge.png', + 'notifications_ttl' => 604800, + 'notifications_require_interaction' => false, + 'notifications_silent' => false, +]); + +$projects = Pushpad\Project::findAll(); + +$project = Pushpad\Project::find(123); +$project->update(['name' => 'The New Project Name']); +$project->delete(); +``` -If you try to send a notification to a user ID, but that user is not subscribed, that ID is simply ignored. +## Managing senders -The methods above return an array: +Senders hold the VAPID credentials used for Web Push: -- `'id'` is the id of the notification on Pushpad -- `'scheduled'` is the estimated reach of the notification (i.e. the number of devices to which the notification will be sent, which can be different from the number of users, since a user may receive notifications on multiple devices) -- `'uids'` (`deliver_to` only) are the user IDs that will be actually reached by the notification because they are subscribed to your notifications. For example if you send a notification to `['uid1', 'uid2', 'uid3']`, but only `'uid1'` is subscribed, you will get `['uid1']` in response. Note that if a user has unsubscribed after the last notification sent to him, he may still be reported for one time as subscribed (this is due to the way the W3C Push API works). -- `'send_at'` is present only for scheduled notifications. The fields `'scheduled'` and `'uids'` are not available in this case. +```php +$sender = Pushpad\Sender::create([ + 'name' => 'My sender', + // omit the keys below to let Pushpad generate them automatically + // 'vapid_private_key' => '-----BEGIN EC PRIVATE KEY----- ...', + // 'vapid_public_key' => '-----BEGIN PUBLIC KEY----- ...', +]); + +$senders = Pushpad\Sender::findAll(); + +$sender = Pushpad\Sender::find(987); +$sender->update(['name' => 'The New Sender Name']); +$sender->delete(); +``` ## License The library is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). - From 66fdc4922d5778c62a4ac3060bc444f6a826a805 Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Tue, 7 Oct 2025 10:52:42 +0200 Subject: [PATCH 15/18] Remove ext-json requirement because this extension is always enabled --- README.md | 2 +- composer.json | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 52512ee..5ef4256 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Notifications are delivered in real time even when the users are not on your web ### Composer -This package requires PHP 8.0+ with the `ext-curl` and `ext-json` extensions. +This package requires PHP 8.0+ with the `curl` extension. Install the SDK via [Composer](https://getcomposer.org/): diff --git a/composer.json b/composer.json index c5d3535..f3c974e 100644 --- a/composer.json +++ b/composer.json @@ -18,8 +18,7 @@ ], "require": { "php": ">=8.0.0", - "ext-curl": "*", - "ext-json": "*" + "ext-curl": "*" }, "autoload": { "psr-4": { "Pushpad\\" : "lib/" } From 8ae63ea22131d82b266b5d7c5bae9c7e92eb7de7 Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Tue, 7 Oct 2025 11:19:57 +0200 Subject: [PATCH 16/18] Add Pushpad::VERSION --- lib/Pushpad.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/Pushpad.php b/lib/Pushpad.php index dc0a6a6..5eac8ba 100644 --- a/lib/Pushpad.php +++ b/lib/Pushpad.php @@ -11,6 +11,9 @@ */ class Pushpad { + /** Current library version. */ + public const VERSION = '3.0.0'; + /** * API token used to authenticate every request performed by the SDK. */ From f83625bb05723c47a76fd895263ba728125c1de6 Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Tue, 7 Oct 2025 12:16:07 +0200 Subject: [PATCH 17/18] Improve User-Agent header --- lib/HttpClient.php | 6 +++--- lib/Pushpad.php | 5 ++++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/lib/HttpClient.php b/lib/HttpClient.php index 0ea16fb..33bbca7 100644 --- a/lib/HttpClient.php +++ b/lib/HttpClient.php @@ -20,11 +20,11 @@ class HttpClient * @param string $authToken API token granted by Pushpad. * @param string $baseUrl Base endpoint for the REST API. * @param int $timeout Default timeout in seconds for requests. - * @param string|null $userAgent Forces a custom User-Agent header when provided. + * @param string $userAgent Forces a custom User-Agent header when provided. * * @throws \InvalidArgumentException When the authentication token is empty. */ - public function __construct(string $authToken, string $baseUrl = 'https://pushpad.xyz/api/v1', int $timeout = 30, ?string $userAgent = null) + public function __construct(string $authToken, string $baseUrl = 'https://pushpad.xyz/api/v1', int $timeout = 30, string $userAgent = 'pushpad-php') { if ($authToken === '') { throw new \InvalidArgumentException('Auth token must be a non-empty string.'); @@ -33,7 +33,7 @@ public function __construct(string $authToken, string $baseUrl = 'https://pushpa $this->authToken = $authToken; $this->baseUrl = rtrim($baseUrl, '/'); $this->timeout = $timeout; - $this->userAgent = $userAgent ?? 'pushpad-php-sdk/1.0'; + $this->userAgent = $userAgent; } /** diff --git a/lib/Pushpad.php b/lib/Pushpad.php index 5eac8ba..b24c78c 100644 --- a/lib/Pushpad.php +++ b/lib/Pushpad.php @@ -82,10 +82,13 @@ public static function http(): HttpClient return self::$httpClient; } + $userAgent = sprintf('pushpad-php/%s', self::VERSION); + self::$httpClient = new HttpClient( self::$authToken, self::$baseUrl, - self::$timeout + self::$timeout, + $userAgent ); return self::$httpClient; From c2b2ebfccae20ec57f4cb9b25874059114e0df0f Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Tue, 7 Oct 2025 12:58:35 +0200 Subject: [PATCH 18/18] Improve PHPDoc in HttpClient --- lib/HttpClient.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/lib/HttpClient.php b/lib/HttpClient.php index 33bbca7..dc171f3 100644 --- a/lib/HttpClient.php +++ b/lib/HttpClient.php @@ -17,6 +17,8 @@ class HttpClient private string $userAgent; /** + * Initializes the HTTP client with some options. + * * @param string $authToken API token granted by Pushpad. * @param string $baseUrl Base endpoint for the REST API. * @param int $timeout Default timeout in seconds for requests. @@ -39,6 +41,8 @@ public function __construct(string $authToken, string $baseUrl = 'https://pushpa /** * Executes an HTTP request against the Pushpad API. * + * @param string $method HTTP verb used for the request. + * @param string $path Relative path appended to the base URL. * @param array{query?:array, json?:mixed, body?:string, headers?:array, timeout?:int} $options * @return array{status:int, body:mixed, headers:array>, raw_body:?string} * @@ -115,6 +119,8 @@ public function request(string $method, string $path, array $options = []): arra } /** + * Produces the base headers required for API requests. + * * @return list */ private function defaultHeaders(): array @@ -128,7 +134,9 @@ private function defaultHeaders(): array /** * Creates an absolute URL including any query string parameters. * + * @param string $path Request path relative to the base URL. * @param array $query + * @return string */ private function buildUrl(string $path, array $query): string { @@ -144,6 +152,8 @@ private function buildUrl(string $path, array $query): string } /** + * Builds a URL-encoded query string from the provided parameters. + * * @param array $query * @return string */ @@ -174,6 +184,7 @@ private function buildQueryString(array $query): string /** * Decodes the JSON body when possible, returning the raw string otherwise. * + * @param string $rawBody Raw body returned by cURL. * @return mixed */ private function decode(string $rawBody)