From 82e237fc3953ebff4fc376bb49a24d8d487b54ad Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 30 Sep 2025 03:36:16 +0000 Subject: [PATCH] feat: Add PrintNode integrator account management Co-authored-by: luisarmando1234 --- docs/printnode/_index.md | 12 + docs/printnode/api.md | 10 + docs/printnode/integrator-accounts.md | 462 ++++++++++++++++++ src/Api/PrintNode/BasePrintNodeClient.php | 44 ++ src/Api/PrintNode/Entity/Account.php | 183 +++++++ src/Api/PrintNode/PrintNodeClient.php | 1 + src/Api/PrintNode/Requests/AccountRequest.php | 279 +++++++++++ src/Api/PrintNode/Service/AccountService.php | 295 +++++++++++ src/Api/PrintNode/Service/ServiceFactory.php | 2 + .../Api/PrintNode/AccountServiceTest.php | 392 +++++++++++++++ .../Unit/Api/PrintNode/AccountEntityTest.php | 196 ++++++++ .../Unit/Api/PrintNode/AccountRequestTest.php | 149 ++++++ 12 files changed, 2025 insertions(+) create mode 100644 docs/printnode/integrator-accounts.md create mode 100644 src/Api/PrintNode/Entity/Account.php create mode 100644 src/Api/PrintNode/Requests/AccountRequest.php create mode 100644 src/Api/PrintNode/Service/AccountService.php create mode 100644 tests/Feature/Api/PrintNode/AccountServiceTest.php create mode 100644 tests/Unit/Api/PrintNode/AccountEntityTest.php create mode 100644 tests/Unit/Api/PrintNode/AccountRequestTest.php diff --git a/docs/printnode/_index.md b/docs/printnode/_index.md index 93cc08b..f08378c 100644 --- a/docs/printnode/_index.md +++ b/docs/printnode/_index.md @@ -2,3 +2,15 @@ title: PrintNode sort: 3 --- + +## Available Documentation + +- [Overview](overview.md) - Introduction to PrintNode integration +- [API](api.md) - Working with the PrintNode API client +- [Entities](entities.md) - PrintNode entity models +- [Computer Service](computer-service.md) - Managing computers +- [Printer Service](printer-service.md) - Managing printers +- [PrintJob Service](printjob-service.md) - Managing print jobs +- [Print Task](print-task.md) - Creating print tasks +- [Whoami Service](whoami-service.md) - Account information +- [Integrator Accounts](integrator-accounts.md) - Managing child accounts for multi-tenant applications diff --git a/docs/printnode/api.md b/docs/printnode/api.md index eff1c44..45c816f 100644 --- a/docs/printnode/api.md +++ b/docs/printnode/api.md @@ -53,6 +53,16 @@ $client->printers->all(); More information about each service can be found on that service's doc page. +### Available Services + +The PrintNode client provides the following services: + +- `accounts` - Manage child accounts (Integrator accounts only) +- `computers` - Manage computers +- `printers` - Manage printers +- `printJobs` - Manage print jobs +- `whoami` - Get account information + ## Resources A resource class represents some kind of resource retrieved from the PrintNode API, such as a printer or computer. When the `Printing` facade is used, the PrintNode entity objects will contain a reference to their relevant api resource objects as well. diff --git a/docs/printnode/integrator-accounts.md b/docs/printnode/integrator-accounts.md new file mode 100644 index 0000000..22ba805 --- /dev/null +++ b/docs/printnode/integrator-accounts.md @@ -0,0 +1,462 @@ +# Integrator Accounts + +Integrator accounts allow you to create and manage child accounts for your customers or end-users. This feature is particularly useful for SaaS applications that need to provide isolated printing capabilities to their customers. + +## Prerequisites + +Before using integrator accounts, you need to: + +1. Sign up for a PrintNode account +2. Upgrade your account to an Integrator Account through the PrintNode dashboard +3. Obtain your Integrator Account API key + +## Basic Usage + +### Creating a Child Account + +You can create child accounts programmatically using the `accounts` service: + +```php +use Rawilk\Printing\Facades\Printing; +use Rawilk\Printing\Api\PrintNode\Requests\AccountRequest; + +$client = Printing::driver('printnode')->getClient(); + +// Create a child account using an array +$account = $client->accounts->create([ + 'email' => 'customer@example.com', + 'password' => 'securepassword123', + 'creatorRef' => 'customer-123', // Your internal customer ID + 'apiKeys' => ['development', 'production'], // Auto-generate API keys + 'tags' => [ + 'plan' => 'premium', + 'region' => 'us-west' + ] +]); + +// Or use the AccountRequest builder for a more fluent interface +$request = AccountRequest::make() + ->email('customer@example.com') + ->password('securepassword123') + ->creatorRef('customer-123') + ->apiKeys(['development', 'production']) + ->addTag('plan', 'premium') + ->addTag('region', 'us-west'); + +$account = $client->accounts->create($request); +``` + +### Managing Child Accounts + +#### Listing All Child Accounts + +```php +$accounts = $client->accounts->all(); + +foreach ($accounts as $account) { + echo "Account ID: {$account->id}, Email: {$account->email}\n"; + echo "Status: " . ($account->isActive() ? 'Active' : 'Suspended') . "\n"; +} +``` + +#### Getting a Specific Account + +```php +$accountId = 12345; +$account = $client->accounts->get($accountId); + +// Check account status +if ($account->isSuspended()) { + echo "Account is suspended\n"; +} + +// Get account statistics +$stats = $account->getStatsSummary(); +echo "Printers: {$stats['printers']}, Print Jobs: {$stats['print_jobs']}\n"; +``` + +#### Updating Account Information + +```php +$accountId = 12345; +$account = $client->accounts->update($accountId, [ + 'email' => 'newemail@example.com' +]); +``` + +#### Suspending and Activating Accounts + +```php +// Suspend an account +$account = $client->accounts->suspend($accountId); + +// Activate a suspended account +$account = $client->accounts->activate($accountId); +``` + +#### Deleting an Account + +```php +$success = $client->accounts->delete($accountId); +``` + +### Managing API Keys + +#### Listing API Keys + +```php +$apiKeys = $client->accounts->apiKeys($accountId); + +foreach ($apiKeys as $apiKey) { + echo "Key: {$apiKey['key']}, Description: {$apiKey['description']}\n"; +} +``` + +#### Creating a New API Key + +```php +$newKey = $client->accounts->createApiKey($accountId, 'Staging Environment'); +echo "New API Key: {$newKey['key']}\n"; +``` + +#### Deleting an API Key + +```php +$success = $client->accounts->deleteApiKey($accountId, 'api-key-to-delete'); +``` + +### Managing Tags + +Tags allow you to store metadata about child accounts, such as subscription plans, features, or custom identifiers. + +#### Getting Tags + +```php +$tags = $client->accounts->tags($accountId); +echo "Plan: {$tags['plan']}\n"; +``` + +#### Updating Tags + +```php +$tags = $client->accounts->updateTags($accountId, [ + 'plan' => 'enterprise', + 'support' => '24/7', + 'custom_field' => 'value' +]); +``` + +#### Deleting a Tag + +```php +$success = $client->accounts->deleteTag($accountId, 'obsolete-tag'); +``` + +### Account Statistics + +Get detailed statistics about child accounts: + +```php +// Statistics for a specific account +$stats = $client->accounts->stats($accountId); + +// Statistics for all child accounts +$allStats = $client->accounts->stats(); +``` + +### Downloading Account Data + +Download all data associated with a child account: + +```php +$data = $client->accounts->download($accountId); + +// The download includes: +// - Account information +// - Computers +// - Printers +// - Print jobs +// - API keys +// - Tags +``` + +## Acting on Behalf of Child Accounts + +One of the most powerful features of integrator accounts is the ability to perform actions on behalf of child accounts. This allows you to manage printers, submit print jobs, and perform other operations as if you were the child account. + +### Using Account ID + +```php +// Start acting as a child account +$client->accounts->actAsChildAccount($accountId); + +// Now all subsequent API calls will be made on behalf of the child account +$printers = $client->printers->all(); // Gets the child account's printers +$printJob = $client->printJobs->create(...); // Creates a print job for the child account + +// Stop acting as the child account +$client->accounts->stopActingAsChildAccount(); +``` + +### Using Creator Reference + +If you prefer to use your own internal customer IDs: + +```php +// Act as a child account using creator reference +$client->accounts->actAsChildAccountByRef('customer-123'); + +// Perform operations... +$computers = $client->computers->all(); + +// Stop acting as the child account +$client->accounts->stopActingAsChildAccount(); +``` + +### Chaining Operations + +You can chain the acting methods for a more fluent interface: + +```php +$printers = $client->accounts + ->actAsChildAccount($accountId) + ->printers + ->all(); + +// Don't forget to stop acting when done +$client->accounts->stopActingAsChildAccount(); +``` + +## Working with the Account Entity + +The `Account` entity provides several helper methods: + +```php +$account = $client->accounts->get($accountId); + +// Check account status +$isActive = $account->isActive(); +$isSuspended = $account->isSuspended(); + +// Check account type +$isIntegrator = $account->isIntegrator(); +$isChildAccount = $account->isChildAccount(); + +// Check sub-account capabilities +if ($account->canCreateSubAccounts()) { + $remaining = $account->remainingSubAccounts(); + echo "Can create {$remaining} more sub-accounts\n"; +} + +// Work with API keys +if ($account->hasApiKeys()) { + $prodKey = $account->getApiKey('Production'); + if ($prodKey) { + echo "Production key: {$prodKey['key']}\n"; + } +} + +// Work with tags +if ($account->hasTag('plan')) { + $plan = $account->getTag('plan'); + echo "Current plan: {$plan}\n"; +} + +// Get statistics summary +$stats = $account->getStatsSummary(); +``` + +## Best Practices + +### 1. Use Creator References + +Always set a `creatorRef` when creating child accounts. This allows you to link PrintNode accounts with your internal customer records: + +```php +$account = $client->accounts->create([ + 'email' => 'customer@example.com', + 'password' => 'securepassword', + 'creatorRef' => "customer-{$customerId}", // Your internal ID +]); +``` + +### 2. Generate API Keys Automatically + +Instead of sharing your integrator API key, generate unique API keys for each child account: + +```php +$account = $client->accounts->create([ + // ... other fields + 'apiKeys' => ['default', 'backup'], // Auto-generate keys +]); + +// Or create them later +$apiKey = $client->accounts->createApiKey($account->id, 'production'); +``` + +### 3. Use Tags for Metadata + +Tags are perfect for storing subscription information, feature flags, or other metadata: + +```php +$client->accounts->updateTags($accountId, [ + 'subscription_plan' => 'premium', + 'subscription_expires' => '2024-12-31', + 'features' => json_encode(['advanced_printing', 'priority_support']), + 'monthly_limit' => 1000, +]); +``` + +### 4. Handle Account Limits + +Check if you can create more child accounts before attempting to create one: + +```php +$integratorAccount = $client->whoami->get(); +if ($integratorAccount->remainingSubAccounts() > 0) { + // Safe to create a new child account + $account = $client->accounts->create(...); +} else { + // Handle limit reached + throw new Exception('Child account limit reached'); +} +``` + +### 5. Clean Up When Acting as Child + +Always stop acting as a child account when you're done: + +```php +try { + $client->accounts->actAsChildAccount($accountId); + + // Perform operations... + +} finally { + // Ensure we stop acting as the child account + $client->accounts->stopActingAsChildAccount(); +} +``` + +### 6. Monitor Account Activity + +Regularly check account statistics to monitor usage: + +```php +$stats = $client->accounts->stats($accountId); + +if ($stats['print_jobs'] > 1000) { + // Maybe upgrade their plan or notify them +} +``` + +## Error Handling + +When working with integrator accounts, you should handle common errors: + +```php +use Rawilk\Printing\Api\PrintNode\Exceptions\AuthenticationFailure; +use Rawilk\Printing\Api\PrintNode\Exceptions\UnexpectedValue; + +try { + $account = $client->accounts->create([ + 'email' => 'customer@example.com', + 'password' => 'pass', // Too short + ]); +} catch (InvalidArgumentException $e) { + // Handle validation errors + echo "Validation error: {$e->getMessage()}\n"; +} catch (AuthenticationFailure $e) { + // Handle authentication issues + echo "Authentication failed: {$e->getMessage()}\n"; +} catch (UnexpectedValue $e) { + // Handle API response errors + echo "API error: {$e->getMessage()}\n"; +} +``` + +## Complete Example + +Here's a complete example of creating and managing a child account: + +```php +use Rawilk\Printing\Facades\Printing; +use Rawilk\Printing\Api\PrintNode\Requests\AccountRequest; + +// Initialize the client +$client = Printing::driver('printnode')->getClient(); + +// Create a child account for a new customer +$request = AccountRequest::make() + ->email('customer@example.com') + ->password('securePassword123!') + ->creatorRef('customer-456') + ->apiKeys(['production']) + ->addTag('plan', 'premium') + ->addTag('created_at', date('Y-m-d')); + +$account = $client->accounts->create($request); + +echo "Created account ID: {$account->id}\n"; + +// Get the generated API key +$apiKeys = $client->accounts->apiKeys($account->id); +$productionKey = $apiKeys->firstWhere('description', 'production'); + +echo "Production API Key: {$productionKey['key']}\n"; + +// Act as the child account to set up their printers +$client->accounts->actAsChildAccount($account->id); + +// Get available computers (the customer needs to install PrintNode client) +$computers = $client->computers->all(); + +if ($computers->isNotEmpty()) { + // Get printers from the first computer + $printers = $client->printers->all(); + + foreach ($printers as $printer) { + echo "Found printer: {$printer->name}\n"; + + // Create a test print job + $printJob = $printer->print( + content: 'Test page from integrator account', + title: 'Test Print' + ); + + echo "Created print job: {$printJob->id}\n"; + } +} + +// Stop acting as the child account +$client->accounts->stopActingAsChildAccount(); + +// Later, check account statistics +$stats = $client->accounts->stats($account->id); +echo "Total print jobs: {$stats['print_jobs']}\n"; + +// Update account tags based on usage +if ($stats['print_jobs'] > 100) { + $client->accounts->updateTags($account->id, [ + 'usage_tier' => 'high', + ]); +} + +// Suspend account if needed (e.g., payment failed) +if ($paymentFailed) { + $client->accounts->suspend($account->id); + echo "Account suspended due to payment failure\n"; +} + +// Reactivate when payment is resolved +if ($paymentResolved) { + $client->accounts->activate($account->id); + echo "Account reactivated\n"; +} +``` + +## Additional Resources + +- [PrintNode API Documentation](https://www.printnode.com/en/docs/api/curl#account-download-management) +- [PrintNode Pricing](https://www.printnode.com/en/pricing) - Information about Integrator Account pricing +- [PrintNode Support](https://www.printnode.com/en/support) - Get help with your Integrator Account \ No newline at end of file diff --git a/src/Api/PrintNode/BasePrintNodeClient.php b/src/Api/PrintNode/BasePrintNodeClient.php index 1928344..aedce44 100644 --- a/src/Api/PrintNode/BasePrintNodeClient.php +++ b/src/Api/PrintNode/BasePrintNodeClient.php @@ -33,6 +33,8 @@ class BasePrintNodeClient implements PrintNodeClientInterface private RequestOptions $defaultOpts; + private array $defaultHeaders = []; + public function __construct(#[SensitiveParameter] string|array|null $config = []) { if (is_string($config)) { @@ -68,6 +70,43 @@ public function setApiKey(string $apiKey): static return $this; } + /** + * Set a default header to be included in all requests. + * + * @param string $name + * @param string $value + * @return static + */ + public function setDefaultHeader(string $name, string $value): static + { + $this->defaultHeaders[$name] = $value; + + return $this; + } + + /** + * Remove a default header. + * + * @param string $name + * @return static + */ + public function removeDefaultHeader(string $name): static + { + unset($this->defaultHeaders[$name]); + + return $this; + } + + /** + * Get all default headers. + * + * @return array + */ + public function getDefaultHeaders(): array + { + return $this->defaultHeaders; + } + public function request( string $method, string $path, @@ -79,6 +118,11 @@ public function request( $opts = $defaultRequestOpts->merge($opts, true); + // Merge default headers with request headers + if (! empty($this->defaultHeaders)) { + $opts->headers = array_merge($this->defaultHeaders, $opts->headers ?? []); + } + $baseUrl = $opts->apiBase ?: $this->getApiBase(); $requestor = new PrintNodeApiRequestor( diff --git a/src/Api/PrintNode/Entity/Account.php b/src/Api/PrintNode/Entity/Account.php new file mode 100644 index 0000000..46977f0 --- /dev/null +++ b/src/Api/PrintNode/Entity/Account.php @@ -0,0 +1,183 @@ +suspended ?? false); + } + + /** + * Check if the account is suspended. + * + * @return bool + */ + public function isSuspended(): bool + { + return $this->suspended ?? false; + } + + /** + * Check if the account can create sub-accounts. + * + * @return bool + */ + public function canCreateSubAccounts(): bool + { + return $this->canCreateSubAccounts ?? false; + } + + /** + * Get the number of remaining sub-accounts that can be created. + * + * @return int|null + */ + public function remainingSubAccounts(): ?int + { + if (! $this->canCreateSubAccounts()) { + return 0; + } + + if ($this->maxSubAccounts === null) { + return null; // unlimited + } + + $childAccounts = $this->childAccounts ?? 0; + + return max(0, $this->maxSubAccounts - $childAccounts); + } + + /** + * Check if the account has API keys. + * + * @return bool + */ + public function hasApiKeys(): bool + { + return ! empty($this->apiKeys); + } + + /** + * Get a specific API key by description. + * + * @param string $description + * @return array|null + */ + public function getApiKey(string $description): ?array + { + if (! $this->hasApiKeys()) { + return null; + } + + foreach ($this->apiKeys as $apiKey) { + if (($apiKey['description'] ?? '') === $description) { + return $apiKey; + } + } + + return null; + } + + /** + * Check if the account has a specific tag. + * + * @param string $tagName + * @return bool + */ + public function hasTag(string $tagName): bool + { + return isset($this->tags[$tagName]); + } + + /** + * Get a specific tag value. + * + * @param string $tagName + * @param mixed $default + * @return mixed + */ + public function getTag(string $tagName, mixed $default = null): mixed + { + return $this->tags[$tagName] ?? $default; + } + + /** + * Check if the account is an integrator account. + * + * @return bool + */ + public function isIntegrator(): bool + { + return ! empty($this->integrator); + } + + /** + * Check if the account is a child account. + * + * @return bool + */ + public function isChildAccount(): bool + { + return ! empty($this->creatorEmail) || ! empty($this->creatorRef); + } + + /** + * Get account statistics summary. + * + * @return array + */ + public function getStatsSummary(): array + { + return [ + 'computers' => $this->numComputers ?? 0, + 'printers' => $this->numPrinters ?? 0, + 'print_jobs' => $this->numPrintJobs ?? 0, + 'total_prints' => $this->totalPrints ?? 0, + 'child_accounts' => $this->childAccounts ?? 0, + 'connected_clients' => $this->connectedClients ?? 0, + ]; + } +} \ No newline at end of file diff --git a/src/Api/PrintNode/PrintNodeClient.php b/src/Api/PrintNode/PrintNodeClient.php index 9eedbf4..6c2daf4 100644 --- a/src/Api/PrintNode/PrintNodeClient.php +++ b/src/Api/PrintNode/PrintNodeClient.php @@ -9,6 +9,7 @@ /** * Client used to send requests to PrintNode's API. * + * @property-read \Rawilk\Printing\Api\PrintNode\Service\AccountService $accounts * @property-read \Rawilk\Printing\Api\PrintNode\Service\ComputerService $computers * @property-read \Rawilk\Printing\Api\PrintNode\Service\PrinterService $printers * @property-read \Rawilk\Printing\Api\PrintNode\Service\PrintJobService $printJobs diff --git a/src/Api/PrintNode/Requests/AccountRequest.php b/src/Api/PrintNode/Requests/AccountRequest.php new file mode 100644 index 0000000..8fdc48b --- /dev/null +++ b/src/Api/PrintNode/Requests/AccountRequest.php @@ -0,0 +1,279 @@ +setFromArray($data); + } + } + + /** + * Set the first name for the account (deprecated, use "-"). + * + * @param string $firstname + * @return static + */ + public function firstname(string $firstname = '-'): static + { + $this->data['Account']['firstname'] = $firstname; + + return $this; + } + + /** + * Set the last name for the account (deprecated, use "-"). + * + * @param string $lastname + * @return static + */ + public function lastname(string $lastname = '-'): static + { + $this->data['Account']['lastname'] = $lastname; + + return $this; + } + + /** + * Set the email address for the account. + * + * @param string $email + * @return static + */ + public function email(string $email): static + { + if (! filter_var($email, FILTER_VALIDATE_EMAIL)) { + throw new InvalidArgumentException("Invalid email address: {$email}"); + } + + $this->data['Account']['email'] = $email; + + return $this; + } + + /** + * Set the password for the account. + * + * @param string $password + * @return static + */ + public function password(string $password): static + { + if (strlen($password) < 8) { + throw new InvalidArgumentException('Password must be at least 8 characters long'); + } + + $this->data['Account']['password'] = $password; + + return $this; + } + + /** + * Set the creator reference for the account. + * + * @param string $creatorRef + * @return static + */ + public function creatorRef(string $creatorRef): static + { + $this->data['Account']['creatorRef'] = $creatorRef; + + return $this; + } + + /** + * Add API keys to be generated for the account. + * + * @param array|string $apiKeys + * @return static + */ + public function apiKeys(array|string $apiKeys): static + { + if (is_string($apiKeys)) { + $apiKeys = [$apiKeys]; + } + + $this->data['ApiKeys'] = $apiKeys; + + return $this; + } + + /** + * Add a single API key to be generated. + * + * @param string $apiKeyName + * @return static + */ + public function addApiKey(string $apiKeyName): static + { + if (! isset($this->data['ApiKeys'])) { + $this->data['ApiKeys'] = []; + } + + $this->data['ApiKeys'][] = $apiKeyName; + + return $this; + } + + /** + * Set tags for the account. + * + * @param array $tags + * @return static + */ + public function tags(array $tags): static + { + $this->data['Tags'] = $tags; + + return $this; + } + + /** + * Add a single tag to the account. + * + * @param string $name + * @param mixed $value + * @return static + */ + public function addTag(string $name, mixed $value): static + { + if (! isset($this->data['Tags'])) { + $this->data['Tags'] = []; + } + + $this->data['Tags'][$name] = $value; + + return $this; + } + + /** + * Set all data from an array. + * + * @param array $data + * @return static + */ + public function setFromArray(array $data): static + { + // Handle flat array format + if (isset($data['email'])) { + $this->email($data['email']); + } + + if (isset($data['password'])) { + $this->password($data['password']); + } + + if (isset($data['firstname'])) { + $this->firstname($data['firstname']); + } + + if (isset($data['lastname'])) { + $this->lastname($data['lastname']); + } + + if (isset($data['creatorRef'])) { + $this->creatorRef($data['creatorRef']); + } + + if (isset($data['apiKeys'])) { + $this->apiKeys($data['apiKeys']); + } + + if (isset($data['tags'])) { + $this->tags($data['tags']); + } + + // Handle nested format (as used by PrintNode API) + if (isset($data['Account'])) { + if (isset($data['Account']['email'])) { + $this->email($data['Account']['email']); + } + + if (isset($data['Account']['password'])) { + $this->password($data['Account']['password']); + } + + if (isset($data['Account']['firstname'])) { + $this->firstname($data['Account']['firstname']); + } + + if (isset($data['Account']['lastname'])) { + $this->lastname($data['Account']['lastname']); + } + + if (isset($data['Account']['creatorRef'])) { + $this->creatorRef($data['Account']['creatorRef']); + } + } + + if (isset($data['ApiKeys'])) { + $this->apiKeys($data['ApiKeys']); + } + + if (isset($data['Tags'])) { + $this->tags($data['Tags']); + } + + return $this; + } + + /** + * Validate the request data. + * + * @throws InvalidArgumentException + */ + public function validate(): void + { + if (! isset($this->data['Account']['email'])) { + throw new InvalidArgumentException('Email is required for creating an account'); + } + + if (! isset($this->data['Account']['password'])) { + throw new InvalidArgumentException('Password is required for creating an account'); + } + + // Set defaults for deprecated fields if not set + if (! isset($this->data['Account']['firstname'])) { + $this->firstname(); + } + + if (! isset($this->data['Account']['lastname'])) { + $this->lastname(); + } + } + + /** + * Convert to array format for API request. + * + * @return array + */ + public function toArray(): array + { + $this->validate(); + + return $this->data; + } + + /** + * Create a new instance from array. + * + * @param array $data + * @return static + */ + public static function make(array $data = []): static + { + return new static($data); + } +} \ No newline at end of file diff --git a/src/Api/PrintNode/Service/AccountService.php b/src/Api/PrintNode/Service/AccountService.php new file mode 100644 index 0000000..55571bc --- /dev/null +++ b/src/Api/PrintNode/Service/AccountService.php @@ -0,0 +1,295 @@ +client->request( + 'POST', + '/account', + $data->toArray(), + [], + Account::class + ); + + return $response; + } + + /** + * Get information about a specific child account. + * + * @param int $accountId + * @return Account + */ + public function get(int $accountId): Account + { + return $this->client->request( + 'GET', + "/account/{$accountId}", + [], + [], + Account::class + ); + } + + /** + * Get all child accounts for the current Integrator account. + * + * @return Collection + */ + public function all(): Collection + { + return $this->client->requestCollection( + 'GET', + '/account', + [], + [], + Account::class + ); + } + + /** + * Update a child account. + * + * @param int $accountId + * @param array $data + * @return Account + */ + public function update(int $accountId, array $data): Account + { + return $this->client->request( + 'PATCH', + "/account/{$accountId}", + $data, + [], + Account::class + ); + } + + /** + * Delete a child account. + * + * @param int $accountId + * @return bool + */ + public function delete(int $accountId): bool + { + $response = $this->client->request( + 'DELETE', + "/account/{$accountId}" + ); + + return $response === true || $response === null; + } + + /** + * Suspend a child account. + * + * @param int $accountId + * @return Account + */ + public function suspend(int $accountId): Account + { + return $this->update($accountId, ['suspended' => true]); + } + + /** + * Activate a suspended child account. + * + * @param int $accountId + * @return Account + */ + public function activate(int $accountId): Account + { + return $this->update($accountId, ['suspended' => false]); + } + + /** + * Get account statistics. + * + * @param int|null $accountId If null, gets stats for all child accounts + * @return array + */ + public function stats(?int $accountId = null): array + { + $path = $accountId ? "/account/{$accountId}/stats" : '/account/stats'; + + return $this->client->request( + 'GET', + $path + ); + } + + /** + * Download account data. + * + * @param int $accountId + * @return array + */ + public function download(int $accountId): array + { + return $this->client->request( + 'GET', + "/account/{$accountId}/download" + ); + } + + /** + * Get API keys for a child account. + * + * @param int $accountId + * @return Collection + */ + public function apiKeys(int $accountId): Collection + { + return $this->client->requestCollection( + 'GET', + "/account/{$accountId}/apikeys" + ); + } + + /** + * Create a new API key for a child account. + * + * @param int $accountId + * @param string $description + * @return array + */ + public function createApiKey(int $accountId, string $description): array + { + return $this->client->request( + 'POST', + "/account/{$accountId}/apikey", + ['description' => $description] + ); + } + + /** + * Delete an API key from a child account. + * + * @param int $accountId + * @param string $apiKey + * @return bool + */ + public function deleteApiKey(int $accountId, string $apiKey): bool + { + $response = $this->client->request( + 'DELETE', + "/account/{$accountId}/apikey/{$apiKey}" + ); + + return $response === true || $response === null; + } + + /** + * Get tags for a child account. + * + * @param int $accountId + * @return array + */ + public function tags(int $accountId): array + { + return $this->client->request( + 'GET', + "/account/{$accountId}/tags" + ); + } + + /** + * Update tags for a child account. + * + * @param int $accountId + * @param array $tags + * @return array + */ + public function updateTags(int $accountId, array $tags): array + { + return $this->client->request( + 'PATCH', + "/account/{$accountId}/tags", + $tags + ); + } + + /** + * Delete a tag from a child account. + * + * @param int $accountId + * @param string $tagName + * @return bool + */ + public function deleteTag(int $accountId, string $tagName): bool + { + $response = $this->client->request( + 'DELETE', + "/account/{$accountId}/tag/{$tagName}" + ); + + return $response === true || $response === null; + } + + /** + * Perform actions on behalf of a child account. + * This sets the X-Child-Account-By-Id header for subsequent requests. + * + * @param int $accountId + * @return static + */ + public function actAsChildAccount(int $accountId): static + { + $this->client->setDefaultHeader('X-Child-Account-By-Id', (string) $accountId); + + return $this; + } + + /** + * Perform actions on behalf of a child account using creator reference. + * This sets the X-Child-Account-By-CreatorRef header for subsequent requests. + * + * @param string $creatorRef + * @return static + */ + public function actAsChildAccountByRef(string $creatorRef): static + { + $this->client->setDefaultHeader('X-Child-Account-By-CreatorRef', $creatorRef); + + return $this; + } + + /** + * Stop acting on behalf of a child account. + * + * @return static + */ + public function stopActingAsChildAccount(): static + { + $this->client->removeDefaultHeader('X-Child-Account-By-Id'); + $this->client->removeDefaultHeader('X-Child-Account-By-CreatorRef'); + + return $this; + } +} \ No newline at end of file diff --git a/src/Api/PrintNode/Service/ServiceFactory.php b/src/Api/PrintNode/Service/ServiceFactory.php index 3973a30..b9660e8 100644 --- a/src/Api/PrintNode/Service/ServiceFactory.php +++ b/src/Api/PrintNode/Service/ServiceFactory.php @@ -13,6 +13,7 @@ * * @internal * + * @property-read \Rawilk\Printing\Api\PrintNode\Service\AccountService $accounts * @property-read \Rawilk\Printing\Api\PrintNode\Service\ComputerService $computers * @property-read \Rawilk\Printing\Api\PrintNode\Service\PrinterService $printers * @property-read \Rawilk\Printing\Api\PrintNode\Service\PrintJobService $printJobs @@ -23,6 +24,7 @@ class ServiceFactory protected array $services = []; private static array $classMap = [ + 'accounts' => AccountService::class, 'computers' => ComputerService::class, 'printers' => PrinterService::class, 'printJobs' => PrintJobService::class, diff --git a/tests/Feature/Api/PrintNode/AccountServiceTest.php b/tests/Feature/Api/PrintNode/AccountServiceTest.php new file mode 100644 index 0000000..642463c --- /dev/null +++ b/tests/Feature/Api/PrintNode/AccountServiceTest.php @@ -0,0 +1,392 @@ +client = Mockery::mock(PrintNodeClient::class); + $this->service = new AccountService($this->client); +}); + +it('can create a child account', function () { + $accountData = [ + 'email' => 'child@example.com', + 'password' => 'securepassword123', + 'creatorRef' => 'customer-123', + 'apiKeys' => ['development', 'production'], + 'tags' => ['plan' => 'premium'], + ]; + + $expectedResponse = new Account([ + 'id' => 12345, + 'email' => 'child@example.com', + 'creatorRef' => 'customer-123', + 'state' => 'active', + ]); + + $this->client->shouldReceive('request') + ->once() + ->with( + 'POST', + '/account', + Mockery::on(function ($data) { + return $data['Account']['email'] === 'child@example.com' + && $data['Account']['password'] === 'securepassword123' + && $data['Account']['creatorRef'] === 'customer-123' + && $data['ApiKeys'] === ['development', 'production'] + && $data['Tags']['plan'] === 'premium'; + }), + [], + Account::class + ) + ->andReturn($expectedResponse); + + $result = $this->service->create($accountData); + + expect($result)->toBeInstanceOf(Account::class); + expect($result->id)->toBe(12345); + expect($result->email)->toBe('child@example.com'); +}); + +it('can create a child account using AccountRequest', function () { + $request = AccountRequest::make() + ->email('child@example.com') + ->password('securepassword123') + ->creatorRef('customer-456') + ->apiKeys(['api-key-1', 'api-key-2']) + ->addTag('tier', 'gold'); + + $expectedResponse = new Account([ + 'id' => 67890, + 'email' => 'child@example.com', + 'creatorRef' => 'customer-456', + ]); + + $this->client->shouldReceive('request') + ->once() + ->with( + 'POST', + '/account', + Mockery::on(function ($data) { + return $data['Account']['email'] === 'child@example.com' + && $data['Account']['password'] === 'securepassword123' + && $data['Account']['creatorRef'] === 'customer-456' + && $data['ApiKeys'] === ['api-key-1', 'api-key-2'] + && $data['Tags']['tier'] === 'gold'; + }), + [], + Account::class + ) + ->andReturn($expectedResponse); + + $result = $this->service->create($request); + + expect($result)->toBeInstanceOf(Account::class); + expect($result->id)->toBe(67890); +}); + +it('can get a specific child account', function () { + $accountId = 12345; + $expectedResponse = new Account([ + 'id' => $accountId, + 'email' => 'child@example.com', + 'state' => 'active', + ]); + + $this->client->shouldReceive('request') + ->once() + ->with('GET', "/account/{$accountId}", [], [], Account::class) + ->andReturn($expectedResponse); + + $result = $this->service->get($accountId); + + expect($result)->toBeInstanceOf(Account::class); + expect($result->id)->toBe($accountId); +}); + +it('can get all child accounts', function () { + $accounts = [ + new Account(['id' => 1, 'email' => 'child1@example.com']), + new Account(['id' => 2, 'email' => 'child2@example.com']), + new Account(['id' => 3, 'email' => 'child3@example.com']), + ]; + + $this->client->shouldReceive('requestCollection') + ->once() + ->with('GET', '/account', [], [], Account::class) + ->andReturn(collect($accounts)); + + $result = $this->service->all(); + + expect($result)->toBeInstanceOf(\Illuminate\Support\Collection::class); + expect($result)->toHaveCount(3); + expect($result->first())->toBeInstanceOf(Account::class); +}); + +it('can update a child account', function () { + $accountId = 12345; + $updateData = ['suspended' => true]; + + $expectedResponse = new Account([ + 'id' => $accountId, + 'suspended' => true, + ]); + + $this->client->shouldReceive('request') + ->once() + ->with('PATCH', "/account/{$accountId}", $updateData, [], Account::class) + ->andReturn($expectedResponse); + + $result = $this->service->update($accountId, $updateData); + + expect($result)->toBeInstanceOf(Account::class); + expect($result->suspended)->toBeTrue(); +}); + +it('can suspend a child account', function () { + $accountId = 12345; + + $expectedResponse = new Account([ + 'id' => $accountId, + 'suspended' => true, + ]); + + $this->client->shouldReceive('request') + ->once() + ->with('PATCH', "/account/{$accountId}", ['suspended' => true], [], Account::class) + ->andReturn($expectedResponse); + + $result = $this->service->suspend($accountId); + + expect($result)->toBeInstanceOf(Account::class); + expect($result->suspended)->toBeTrue(); +}); + +it('can activate a suspended child account', function () { + $accountId = 12345; + + $expectedResponse = new Account([ + 'id' => $accountId, + 'suspended' => false, + ]); + + $this->client->shouldReceive('request') + ->once() + ->with('PATCH', "/account/{$accountId}", ['suspended' => false], [], Account::class) + ->andReturn($expectedResponse); + + $result = $this->service->activate($accountId); + + expect($result)->toBeInstanceOf(Account::class); + expect($result->suspended)->toBeFalse(); +}); + +it('can delete a child account', function () { + $accountId = 12345; + + $this->client->shouldReceive('request') + ->once() + ->with('DELETE', "/account/{$accountId}") + ->andReturn(null); + + $result = $this->service->delete($accountId); + + expect($result)->toBeTrue(); +}); + +it('can get account statistics', function () { + $accountId = 12345; + $stats = [ + 'computers' => 5, + 'printers' => 10, + 'print_jobs' => 100, + ]; + + $this->client->shouldReceive('request') + ->once() + ->with('GET', "/account/{$accountId}/stats") + ->andReturn($stats); + + $result = $this->service->stats($accountId); + + expect($result)->toBe($stats); +}); + +it('can get statistics for all child accounts', function () { + $stats = [ + 'total_accounts' => 10, + 'active_accounts' => 8, + 'suspended_accounts' => 2, + ]; + + $this->client->shouldReceive('request') + ->once() + ->with('GET', '/account/stats') + ->andReturn($stats); + + $result = $this->service->stats(); + + expect($result)->toBe($stats); +}); + +it('can download account data', function () { + $accountId = 12345; + $downloadData = [ + 'account' => ['id' => $accountId], + 'computers' => [], + 'printers' => [], + ]; + + $this->client->shouldReceive('request') + ->once() + ->with('GET', "/account/{$accountId}/download") + ->andReturn($downloadData); + + $result = $this->service->download($accountId); + + expect($result)->toBe($downloadData); +}); + +it('can get API keys for a child account', function () { + $accountId = 12345; + $apiKeys = [ + ['key' => 'key1', 'description' => 'Development'], + ['key' => 'key2', 'description' => 'Production'], + ]; + + $this->client->shouldReceive('requestCollection') + ->once() + ->with('GET', "/account/{$accountId}/apikeys") + ->andReturn(collect($apiKeys)); + + $result = $this->service->apiKeys($accountId); + + expect($result)->toBeInstanceOf(\Illuminate\Support\Collection::class); + expect($result)->toHaveCount(2); +}); + +it('can create an API key for a child account', function () { + $accountId = 12345; + $description = 'Staging Environment'; + $apiKeyResponse = [ + 'key' => 'new-api-key-123', + 'description' => $description, + ]; + + $this->client->shouldReceive('request') + ->once() + ->with('POST', "/account/{$accountId}/apikey", ['description' => $description]) + ->andReturn($apiKeyResponse); + + $result = $this->service->createApiKey($accountId, $description); + + expect($result)->toBe($apiKeyResponse); +}); + +it('can delete an API key from a child account', function () { + $accountId = 12345; + $apiKey = 'api-key-to-delete'; + + $this->client->shouldReceive('request') + ->once() + ->with('DELETE', "/account/{$accountId}/apikey/{$apiKey}") + ->andReturn(null); + + $result = $this->service->deleteApiKey($accountId, $apiKey); + + expect($result)->toBeTrue(); +}); + +it('can get tags for a child account', function () { + $accountId = 12345; + $tags = [ + 'plan' => 'premium', + 'region' => 'us-west', + ]; + + $this->client->shouldReceive('request') + ->once() + ->with('GET', "/account/{$accountId}/tags") + ->andReturn($tags); + + $result = $this->service->tags($accountId); + + expect($result)->toBe($tags); +}); + +it('can update tags for a child account', function () { + $accountId = 12345; + $tags = [ + 'plan' => 'enterprise', + 'support' => '24/7', + ]; + + $this->client->shouldReceive('request') + ->once() + ->with('PATCH', "/account/{$accountId}/tags", $tags) + ->andReturn($tags); + + $result = $this->service->updateTags($accountId, $tags); + + expect($result)->toBe($tags); +}); + +it('can delete a tag from a child account', function () { + $accountId = 12345; + $tagName = 'obsolete-tag'; + + $this->client->shouldReceive('request') + ->once() + ->with('DELETE', "/account/{$accountId}/tag/{$tagName}") + ->andReturn(null); + + $result = $this->service->deleteTag($accountId, $tagName); + + expect($result)->toBeTrue(); +}); + +it('can act as a child account by ID', function () { + $accountId = 12345; + + $this->client->shouldReceive('setDefaultHeader') + ->once() + ->with('X-Child-Account-By-Id', '12345') + ->andReturn($this->client); + + $result = $this->service->actAsChildAccount($accountId); + + expect($result)->toBe($this->service); +}); + +it('can act as a child account by creator reference', function () { + $creatorRef = 'customer-123'; + + $this->client->shouldReceive('setDefaultHeader') + ->once() + ->with('X-Child-Account-By-CreatorRef', 'customer-123') + ->andReturn($this->client); + + $result = $this->service->actAsChildAccountByRef($creatorRef); + + expect($result)->toBe($this->service); +}); + +it('can stop acting as a child account', function () { + $this->client->shouldReceive('removeDefaultHeader') + ->once() + ->with('X-Child-Account-By-Id') + ->andReturn($this->client); + + $this->client->shouldReceive('removeDefaultHeader') + ->once() + ->with('X-Child-Account-By-CreatorRef') + ->andReturn($this->client); + + $result = $this->service->stopActingAsChildAccount(); + + expect($result)->toBe($this->service); +}); \ No newline at end of file diff --git a/tests/Unit/Api/PrintNode/AccountEntityTest.php b/tests/Unit/Api/PrintNode/AccountEntityTest.php new file mode 100644 index 0000000..7598de7 --- /dev/null +++ b/tests/Unit/Api/PrintNode/AccountEntityTest.php @@ -0,0 +1,196 @@ + false]); + $suspendedAccount = new Account(['suspended' => true]); + $defaultAccount = new Account([]); + + expect($activeAccount->isActive())->toBeTrue(); + expect($suspendedAccount->isActive())->toBeFalse(); + expect($defaultAccount->isActive())->toBeTrue(); // Default is active +}); + +it('can check if account is suspended', function () { + $activeAccount = new Account(['suspended' => false]); + $suspendedAccount = new Account(['suspended' => true]); + + expect($activeAccount->isSuspended())->toBeFalse(); + expect($suspendedAccount->isSuspended())->toBeTrue(); +}); + +it('can check if account can create sub-accounts', function () { + $canCreate = new Account(['canCreateSubAccounts' => true]); + $cannotCreate = new Account(['canCreateSubAccounts' => false]); + $defaultAccount = new Account([]); + + expect($canCreate->canCreateSubAccounts())->toBeTrue(); + expect($cannotCreate->canCreateSubAccounts())->toBeFalse(); + expect($defaultAccount->canCreateSubAccounts())->toBeFalse(); +}); + +it('can calculate remaining sub-accounts', function () { + // Account that can't create sub-accounts + $cannotCreate = new Account(['canCreateSubAccounts' => false]); + expect($cannotCreate->remainingSubAccounts())->toBe(0); + + // Account with unlimited sub-accounts + $unlimited = new Account([ + 'canCreateSubAccounts' => true, + 'maxSubAccounts' => null, + 'childAccounts' => 5, + ]); + expect($unlimited->remainingSubAccounts())->toBeNull(); + + // Account with limited sub-accounts + $limited = new Account([ + 'canCreateSubAccounts' => true, + 'maxSubAccounts' => 10, + 'childAccounts' => 3, + ]); + expect($limited->remainingSubAccounts())->toBe(7); + + // Account at limit + $atLimit = new Account([ + 'canCreateSubAccounts' => true, + 'maxSubAccounts' => 5, + 'childAccounts' => 5, + ]); + expect($atLimit->remainingSubAccounts())->toBe(0); + + // Account over limit (shouldn't happen but handle gracefully) + $overLimit = new Account([ + 'canCreateSubAccounts' => true, + 'maxSubAccounts' => 5, + 'childAccounts' => 7, + ]); + expect($overLimit->remainingSubAccounts())->toBe(0); +}); + +it('can check if account has API keys', function () { + $withKeys = new Account([ + 'apiKeys' => [ + ['key' => 'key1', 'description' => 'Dev'], + ['key' => 'key2', 'description' => 'Prod'], + ], + ]); + $withoutKeys = new Account(['apiKeys' => []]); + $noKeysProperty = new Account([]); + + expect($withKeys->hasApiKeys())->toBeTrue(); + expect($withoutKeys->hasApiKeys())->toBeFalse(); + expect($noKeysProperty->hasApiKeys())->toBeFalse(); +}); + +it('can get API key by description', function () { + $account = new Account([ + 'apiKeys' => [ + ['key' => 'dev-key', 'description' => 'Development'], + ['key' => 'prod-key', 'description' => 'Production'], + ], + ]); + + $devKey = $account->getApiKey('Development'); + expect($devKey)->toBe(['key' => 'dev-key', 'description' => 'Development']); + + $prodKey = $account->getApiKey('Production'); + expect($prodKey)->toBe(['key' => 'prod-key', 'description' => 'Production']); + + $notFound = $account->getApiKey('Staging'); + expect($notFound)->toBeNull(); +}); + +it('can check if account has tags', function () { + $account = new Account([ + 'tags' => [ + 'plan' => 'premium', + 'region' => 'us-west', + ], + ]); + + expect($account->hasTag('plan'))->toBeTrue(); + expect($account->hasTag('region'))->toBeTrue(); + expect($account->hasTag('nonexistent'))->toBeFalse(); +}); + +it('can get tag values', function () { + $account = new Account([ + 'tags' => [ + 'plan' => 'premium', + 'priority' => 1, + 'features' => ['feature1', 'feature2'], + ], + ]); + + expect($account->getTag('plan'))->toBe('premium'); + expect($account->getTag('priority'))->toBe(1); + expect($account->getTag('features'))->toBe(['feature1', 'feature2']); + expect($account->getTag('nonexistent'))->toBeNull(); + expect($account->getTag('nonexistent', 'default'))->toBe('default'); +}); + +it('can check if account is an integrator', function () { + $integrator = new Account([ + 'integrator' => [ + 'maxChildAccounts' => 100, + 'features' => ['api_access'], + ], + ]); + $regular = new Account([]); + + expect($integrator->isIntegrator())->toBeTrue(); + expect($regular->isIntegrator())->toBeFalse(); +}); + +it('can check if account is a child account', function () { + $childByEmail = new Account(['creatorEmail' => 'parent@example.com']); + $childByRef = new Account(['creatorRef' => 'parent-ref']); + $childByBoth = new Account([ + 'creatorEmail' => 'parent@example.com', + 'creatorRef' => 'parent-ref', + ]); + $parentAccount = new Account([]); + + expect($childByEmail->isChildAccount())->toBeTrue(); + expect($childByRef->isChildAccount())->toBeTrue(); + expect($childByBoth->isChildAccount())->toBeTrue(); + expect($parentAccount->isChildAccount())->toBeFalse(); +}); + +it('can get account statistics summary', function () { + $account = new Account([ + 'numComputers' => 5, + 'numPrinters' => 10, + 'numPrintJobs' => 100, + 'totalPrints' => 500, + 'childAccounts' => 3, + 'connectedClients' => 2, + ]); + + $stats = $account->getStatsSummary(); + + expect($stats)->toBe([ + 'computers' => 5, + 'printers' => 10, + 'print_jobs' => 100, + 'total_prints' => 500, + 'child_accounts' => 3, + 'connected_clients' => 2, + ]); + + // Test with missing properties + $emptyAccount = new Account([]); + $emptyStats = $emptyAccount->getStatsSummary(); + + expect($emptyStats)->toBe([ + 'computers' => 0, + 'printers' => 0, + 'print_jobs' => 0, + 'total_prints' => 0, + 'child_accounts' => 0, + 'connected_clients' => 0, + ]); +}); \ No newline at end of file diff --git a/tests/Unit/Api/PrintNode/AccountRequestTest.php b/tests/Unit/Api/PrintNode/AccountRequestTest.php new file mode 100644 index 0000000..aa38d7a --- /dev/null +++ b/tests/Unit/Api/PrintNode/AccountRequestTest.php @@ -0,0 +1,149 @@ +email('test@example.com') + ->password('password123') + ->creatorRef('ref-123') + ->apiKeys(['dev', 'prod']) + ->tags(['plan' => 'premium']); + + $data = $request->toArray(); + + expect($data['Account']['email'])->toBe('test@example.com'); + expect($data['Account']['password'])->toBe('password123'); + expect($data['Account']['creatorRef'])->toBe('ref-123'); + expect($data['Account']['firstname'])->toBe('-'); + expect($data['Account']['lastname'])->toBe('-'); + expect($data['ApiKeys'])->toBe(['dev', 'prod']); + expect($data['Tags']['plan'])->toBe('premium'); +}); + +it('can create an account request from array', function () { + $request = new AccountRequest([ + 'email' => 'test@example.com', + 'password' => 'password123', + 'creatorRef' => 'ref-456', + 'apiKeys' => ['staging'], + 'tags' => ['tier' => 'gold'], + ]); + + $data = $request->toArray(); + + expect($data['Account']['email'])->toBe('test@example.com'); + expect($data['Account']['password'])->toBe('password123'); + expect($data['Account']['creatorRef'])->toBe('ref-456'); + expect($data['ApiKeys'])->toBe(['staging']); + expect($data['Tags']['tier'])->toBe('gold'); +}); + +it('can handle nested array format', function () { + $request = new AccountRequest([ + 'Account' => [ + 'email' => 'nested@example.com', + 'password' => 'nestedpass123', + 'creatorRef' => 'nested-ref', + ], + 'ApiKeys' => ['api1', 'api2'], + 'Tags' => ['nested' => 'true'], + ]); + + $data = $request->toArray(); + + expect($data['Account']['email'])->toBe('nested@example.com'); + expect($data['Account']['password'])->toBe('nestedpass123'); + expect($data['Account']['creatorRef'])->toBe('nested-ref'); + expect($data['ApiKeys'])->toBe(['api1', 'api2']); + expect($data['Tags']['nested'])->toBe('true'); +}); + +it('validates email format', function () { + $request = AccountRequest::make(); + + expect(fn() => $request->email('invalid-email')) + ->toThrow(InvalidArgumentException::class, 'Invalid email address: invalid-email'); +}); + +it('validates password length', function () { + $request = AccountRequest::make(); + + expect(fn() => $request->password('short')) + ->toThrow(InvalidArgumentException::class, 'Password must be at least 8 characters long'); +}); + +it('requires email and password for validation', function () { + $request = AccountRequest::make(); + + expect(fn() => $request->toArray()) + ->toThrow(InvalidArgumentException::class, 'Email is required for creating an account'); + + $request->email('test@example.com'); + + expect(fn() => $request->toArray()) + ->toThrow(InvalidArgumentException::class, 'Password is required for creating an account'); +}); + +it('sets default firstname and lastname if not provided', function () { + $request = AccountRequest::make() + ->email('test@example.com') + ->password('password123'); + + $data = $request->toArray(); + + expect($data['Account']['firstname'])->toBe('-'); + expect($data['Account']['lastname'])->toBe('-'); +}); + +it('can add individual API keys', function () { + $request = AccountRequest::make() + ->email('test@example.com') + ->password('password123') + ->addApiKey('key1') + ->addApiKey('key2') + ->addApiKey('key3'); + + $data = $request->toArray(); + + expect($data['ApiKeys'])->toBe(['key1', 'key2', 'key3']); +}); + +it('can add individual tags', function () { + $request = AccountRequest::make() + ->email('test@example.com') + ->password('password123') + ->addTag('tag1', 'value1') + ->addTag('tag2', 'value2'); + + $data = $request->toArray(); + + expect($data['Tags']['tag1'])->toBe('value1'); + expect($data['Tags']['tag2'])->toBe('value2'); +}); + +it('can handle string apiKeys parameter', function () { + $request = AccountRequest::make() + ->email('test@example.com') + ->password('password123') + ->apiKeys('single-key'); + + $data = $request->toArray(); + + expect($data['ApiKeys'])->toBe(['single-key']); +}); + +it('can override firstname and lastname', function () { + $request = AccountRequest::make() + ->email('test@example.com') + ->password('password123') + ->firstname('John') + ->lastname('Doe'); + + $data = $request->toArray(); + + expect($data['Account']['firstname'])->toBe('John'); + expect($data['Account']['lastname'])->toBe('Doe'); +}); \ No newline at end of file