diff --git a/.gitignore b/.gitignore index 4fbb073..c84ab0c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /vendor/ /composer.lock +/.phpunit.result.cache diff --git a/README.md b/README.md index e339b05..5ef4256 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 `curl` extension. + +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::$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 a default project (optional) ``` -- `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 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::signature_for($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). - 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/" } diff --git a/init.php b/init.php index 335c000..eb9fb3c 100644 --- a/init.php +++ b/init.php @@ -1,4 +1,13 @@ >|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): 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, $body); + + return new self($message, $status, $body, $headers, $rawBody); + } + + /** + * @param mixed $body + */ + private static function buildMessage(int $status, $body): string + { + $baseMessage = sprintf('Pushpad API responded with status %d.', $status); + + $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 @@ +authToken = $authToken; + $this->baseUrl = rtrim($baseUrl, '/'); + $this->timeout = $timeout; + $this->userAgent = $userAgent; + } + + /** + * 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} + * + * @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 + { + $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 NetworkException('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 NetworkException('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, + ]; + } + + /** + * Produces the base headers required for API requests. + * + * @return list + */ + private function defaultHeaders(): array + { + return [ + 'Authorization: Bearer ' . $this->authToken, + 'Accept: application/json', + ]; + } + + /** + * 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 + { + $url = $this->baseUrl . '/' . ltrim($path, '/'); + if (!empty($query)) { + $queryString = $this->buildQueryString($query); + if ($queryString !== '') { + $url .= '?' . $queryString; + } + } + + return $url; + } + + /** + * Builds a URL-encoded query string from the provided parameters. + * + * @param array $query + * @return string + */ + 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); + } + + /** + * 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) + { + $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..ecff4be 100644 --- a/lib/Notification.php +++ b/lib/Notification.php @@ -1,111 +1,143 @@ + * + * @throws \Pushpad\Exception\ApiException When the API response has an unexpected status. + */ + public static function findAll(array $query = [], ?int $projectId = null): 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']; - public function broadcast($options = array()) { - return $this->deliver($this->req_body(null, isset($options['tags']) ? $options['tags'] : null), $options); - } + return array_map(fn (array $item) => new self($item), $items); + } - public function deliver_to($uids, $options = array()) { - if (!isset($uids)) { - $uids = array(); // prevent broadcasting + /** + * 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}"); + self::ensureStatus($response, 200); + $data = $response['body']; + + 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); - } + /** + * 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 + { + $resolvedProjectId = Pushpad::resolveProjectId($projectId); + $response = self::httpPost("/projects/{$resolvedProjectId}/notifications", [ + 'json' => self::filterForCreatePayload($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 $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); + /** + * 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"); + self::ensureStatus($response, 204); + $this->attributes['cancelled'] = true; + } - 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; - } + /** + * 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()}"); + 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..9dd5b2b --- /dev/null +++ b/lib/Project.php @@ -0,0 +1,123 @@ + + * + * @throws \Pushpad\Exception\ApiException When the API response has an unexpected status. + */ + 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); + } + + /** + * 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}"); + self::ensureStatus($response, 200); + $data = $response['body']; + + 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', [ + 'json' => self::filterForCreatePayload($payload), + ]); + self::ensureStatus($response, 201); + $data = $response['body']; + + 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()}"); + self::ensureStatus($response, 200); + $data = $response['body']; + $this->setAttributes($data); + 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()}", [ + 'json' => self::filterForUpdatePayload($payload), + ]); + self::ensureStatus($response, 200); + $data = $response['body']; + $this->setAttributes($data); + 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()}"); + self::ensureStatus($response, 202); + } +} diff --git a/lib/Pushpad.php b/lib/Pushpad.php index 39c4906..b24c78c 100644 --- a/lib/Pushpad.php +++ b/lib/Pushpad.php @@ -1,14 +1,117 @@ */ + 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; + } + + /** + * @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)) { + throw new \InvalidArgumentException(sprintf('Unknown attribute "%s" for %s', $name, static::class)); + } + + 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'])) { + throw new \LogicException('Resource does not have an id yet.'); + } + + 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 + { + return self::http()->request('GET', $path, $options); + } + + /** + * @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 + { + return self::http()->request('POST', $path, $options); + } + + /** + * @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 + { + return self::http()->request('PATCH', $path, $options); + } + + /** + * @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 + { + return self::http()->request('DELETE', $path, $options); + } + + /** + * @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 + { + $status = $response['status'] ?? 0; + if ($status !== $expectedStatusCode) { + throw ApiException::fromResponse($response); + } + } + + /** + * @param array $attributes + * @return array + * + * @throws \InvalidArgumentException When the payload contains unsupported attributes. + */ + protected static function filterForCreatePayload(array $attributes): array + { + $allowed = array_diff( + static::attributes(), + static::readOnlyAttributes() + ); + + return self::filterToAllowedAttributes($attributes, $allowed, true); + } + + /** + * @param array $attributes + * @return array + * + * @throws \InvalidArgumentException When the payload contains unsupported 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 + * @return array + */ + private function filterStoredAttributes(array $attributes): array + { + return self::filterToAllowedAttributes($attributes, static::attributes()); + } + + /** + * @param array $attributes + * @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 + { + 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; + } + + /** + * @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 new file mode 100644 index 0000000..867a1ff --- /dev/null +++ b/lib/Sender.php @@ -0,0 +1,119 @@ + + * + * @throws \Pushpad\Exception\ApiException When the API response has an unexpected status. + */ + 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); + } + + /** + * 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}"); + self::ensureStatus($response, 200); + $data = $response['body']; + + 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', [ + 'json' => self::filterForCreatePayload($payload), + ]); + self::ensureStatus($response, 201); + $data = $response['body']; + + 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()}"); + self::ensureStatus($response, 200); + $data = $response['body']; + $this->setAttributes($data); + 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()}", [ + 'json' => self::filterForUpdatePayload($payload), + ]); + self::ensureStatus($response, 200); + $data = $response['body']; + $this->setAttributes($data); + 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()}"); + self::ensureStatus($response, 204); + } +} diff --git a/lib/Subscription.php b/lib/Subscription.php new file mode 100644 index 0000000..0008fc2 --- /dev/null +++ b/lib/Subscription.php @@ -0,0 +1,208 @@ +, + * tags?: list + * } $query + * @return list + * + * @throws \Pushpad\Exception\ApiException When the API response has an unexpected status. + */ + public static function findAll(array $query = [], ?int $projectId = null): 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 + ); + } + + /** + * Count the subscriptions, with optional filters. + * + * @param array{ + * 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 + { + $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]; + } + + /** + * 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); + $response = self::httpGet("/projects/{$resolvedProjectId}/subscriptions/{$subscriptionId}"); + self::ensureStatus($response, 200); + $data = $response['body']; + + 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); + $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)); + } + + /** + * 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); + $response = self::httpGet("/projects/{$project}/subscriptions/{$this->requireId()}"); + self::ensureStatus($response, 200); + $data = $response['body']; + $this->setAttributes(self::injectProjectId($data, $project)); + 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); + $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; + } + + /** + * 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); + $response = self::httpDelete("/projects/{$project}/subscriptions/{$this->requireId()}"); + 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) { + return $projectId; + } + + if (isset($this->attributes['project_id'])) { + return (int) $this->attributes['project_id']; + } + + return Pushpad::resolveProjectId(null); + } + + /** + * @param array $data + * @param int $projectId + * @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..e1da9ff 100644 --- a/tests/NotificationTest.php +++ b/tests/NotificationTest.php @@ -1,97 +1,241 @@ 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::$authToken = 'token'; + Pushpad::$projectId = 123; + } + + protected function tearDown(): void + { + Pushpad::setHttpClient(null); + Pushpad::$authToken = null; + Pushpad::$projectId = 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(['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 = [ + '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, + 'scheduled' => 5, + ], + 'headers' => [], + 'raw_body' => null, + ]); + + Pushpad::setHttpClient($httpClient); + + $response = Notification::create($payload); + + $this->assertSame(200001, $response['id']); + $this->assertSame(5, $response['scheduled']); + } + + public function testSendNotificationUsesCreate(): void + { + $payload = [ + '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, + 'scheduled' => 1000, + ], + 'headers' => [], + 'raw_body' => null, + ]); + + Pushpad::setHttpClient($httpClient); + + $response = Notification::send($payload); + + $this->assertSame(210000, $response['id']); + $this->assertSame(1000, $response['scheduled']); + } + + 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..54950e5 --- /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/PushpadTest.php b/tests/PushpadTest.php index 9291f9b..b892e39 100644 --- a/tests/PushpadTest.php +++ b/tests/PushpadTest.php @@ -1,19 +1,44 @@ assertEquals($actual, $expected); } + + public function testSignatureRequiresAuthToken(): void + { + Pushpad::$authToken = null; + + $this->expectException(ConfigurationException::class); + Pushpad::signatureFor('user123'); + } + + public function testHttpRequiresAuthToken(): void + { + Pushpad::$authToken = null; + + $this->expectException(ConfigurationException::class); + Pushpad::http(); + } + + public function testResolveProjectIdRequiresConfiguredValue(): void + { + Pushpad::$projectId = null; + + $this->expectException(ConfigurationException::class); + Pushpad::resolveProjectId(null); + } } diff --git a/tests/ResourceTest.php b/tests/ResourceTest.php new file mode 100644 index 0000000..8769dbb --- /dev/null +++ b/tests/ResourceTest.php @@ -0,0 +1,297 @@ + 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(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']); + + $this->assertSame([ + 'foo' => 'bar', + 'other' => 'value', + ], $resource->toArray()); + } + + #[DataProvider('httpMethodProvider')] + public function testHttpHelpersProxyRequestsToHttpClient(string $method, string $wrapper): void + { + Pushpad::$authToken = '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..eee7fb1 --- /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..2688e99 --- /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(['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(); + } +}