diff --git a/README.md b/README.md index 9cfb252..399cc58 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,32 @@ echo $user->email; // "ada@example.com" echo $user->id; // "216174" ``` +### List organizations + +```php +foreach ($forge->organizations()->iterate() as $organization) { + echo $organization->slug.PHP_EOL; +} + +// Or grab a single page: +$page = $forge->organizations()->all(new ListOrganizationsOptions(size: 10)); +foreach ($page as $organization) { + // ... +} +if ($page->hasMore()) { + $next = $forge->organizations()->all(new ListOrganizationsOptions(cursor: $page->nextCursor)); +} +``` + +### Get one organization + +```php +$organization = $forge->organization('acme')->get(); + +echo $organization->name; +echo $organization->slug; +``` + ### Testing your own code The SDK is built on Saloon, so you can fake the Forge API in your own test suite without hitting the network: diff --git a/src/Data/ListOrganizationsOptions.php b/src/Data/ListOrganizationsOptions.php new file mode 100644 index 0000000..17df3ea --- /dev/null +++ b/src/Data/ListOrganizationsOptions.php @@ -0,0 +1,31 @@ + + */ + public function toQuery(): array + { + $query = []; + + if ($this->size !== null) { + $query['page[size]'] = $this->size; + } + + if ($this->cursor !== null) { + $query['page[cursor]'] = $this->cursor; + } + + return $query; + } +} diff --git a/src/Data/Organization.php b/src/Data/Organization.php new file mode 100644 index 0000000..56552d0 --- /dev/null +++ b/src/Data/Organization.php @@ -0,0 +1,89 @@ + $data A JSON:API `OrganizationResource` object + * (the shape under each `data[*]` entry). + */ + public static function from(array $data): self + { + $id = $data['id'] ?? null; + if (! is_string($id)) { + throw new InvalidArgumentException('Organization data is missing the `id` field.'); + } + + $attributes = $data['attributes'] ?? null; + if (! is_array($attributes)) { + throw new InvalidArgumentException('Organization data is missing the `attributes` object.'); + } + + $name = $attributes['name'] ?? null; + if (! is_string($name)) { + throw new InvalidArgumentException('Organization `attributes.name` must be a string.'); + } + + $slug = $attributes['slug'] ?? null; + if (! is_string($slug)) { + throw new InvalidArgumentException('Organization `attributes.slug` must be a string.'); + } + + return new self( + id: $id, + name: $name, + slug: $slug, + createdAt: self::parseDate($attributes['created_at'] ?? null, 'created_at'), + updatedAt: self::parseDate($attributes['updated_at'] ?? null, 'updated_at'), + ); + } + + private static function parseDate(mixed $value, string $field): ?DateTimeImmutable + { + if ($value === null) { + return null; + } + + if (! is_string($value)) { + throw new InvalidArgumentException(sprintf('Organization `attributes.%s` must be a string or null.', $field)); + } + + try { + return new DateTimeImmutable($value); + } catch (Throwable $throwable) { + throw new InvalidArgumentException(sprintf('Organization `attributes.%s` is not a valid date-time.', $field), 0, $throwable); + } + } + + /** + * @return array{id: string, name: string, slug: string, created_at: ?string, updated_at: ?string} + */ + #[Override] + public function jsonSerialize(): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'slug' => $this->slug, + 'created_at' => $this->createdAt?->format(DATE_ATOM), + 'updated_at' => $this->updatedAt?->format(DATE_ATOM), + ]; + } +} diff --git a/src/Data/Page.php b/src/Data/Page.php new file mode 100644 index 0000000..080291f --- /dev/null +++ b/src/Data/Page.php @@ -0,0 +1,58 @@ + + */ +final readonly class Page implements IteratorAggregate, JsonSerializable +{ + /** + * @param list $data + */ + public function __construct( + public array $data, + public ?string $nextCursor, + public ?string $prevCursor, + public int $size, + ) {} + + public function hasMore(): bool + { + return $this->nextCursor !== null; + } + + /** + * @return Generator + */ + #[Override] + public function getIterator(): Generator + { + foreach ($this->data as $key => $item) { + yield $key => $item; + } + } + + /** + * @return array{data: list, next_cursor: ?string, prev_cursor: ?string, size: int} + */ + #[Override] + public function jsonSerialize(): array + { + return [ + 'data' => $this->data, + 'next_cursor' => $this->nextCursor, + 'prev_cursor' => $this->prevCursor, + 'size' => $this->size, + ]; + } +} diff --git a/src/Forge.php b/src/Forge.php index bc57e34..9370a9f 100644 --- a/src/Forge.php +++ b/src/Forge.php @@ -18,6 +18,8 @@ use PhpDevKits\ForgeSdk\Exceptions\UnauthorizedException; use PhpDevKits\ForgeSdk\Exceptions\ValidationException; use PhpDevKits\ForgeSdk\Requests\Me\GetMe; +use PhpDevKits\ForgeSdk\Resources\OrganizationResource; +use PhpDevKits\ForgeSdk\Resources\OrganizationsResource; use RuntimeException; use Saloon\Exceptions\Request\FatalRequestException; use Saloon\Http\Auth\TokenAuthenticator; @@ -32,10 +34,7 @@ final class Forge extends Connector { use AlwaysThrowOnErrors; - public function __construct( - private readonly string $token, - public readonly ?string $organization = null, - ) {} + public function __construct(private readonly string $token, public readonly ?string $defaultOrganization = null) {} public static function fromConfig(?string $path = null): self { @@ -147,6 +146,16 @@ public function me(): User return User::from($data); } + public function organizations(): OrganizationsResource + { + return new OrganizationsResource($this); + } + + public function organization(string $slug): OrganizationResource + { + return new OrganizationResource($this, $slug); + } + #[Override] public function send( Request $request, diff --git a/src/Requests/Organizations/GetOrganization.php b/src/Requests/Organizations/GetOrganization.php new file mode 100644 index 0000000..461f995 --- /dev/null +++ b/src/Requests/Organizations/GetOrganization.php @@ -0,0 +1,22 @@ +slug; + } +} diff --git a/src/Requests/Organizations/GetOrganizations.php b/src/Requests/Organizations/GetOrganizations.php new file mode 100644 index 0000000..d9814df --- /dev/null +++ b/src/Requests/Organizations/GetOrganizations.php @@ -0,0 +1,32 @@ + + */ + #[Override] + protected function defaultQuery(): array + { + return $this->options?->toQuery() ?? []; + } +} diff --git a/src/Resources/OrganizationResource.php b/src/Resources/OrganizationResource.php new file mode 100644 index 0000000..36ec0f8 --- /dev/null +++ b/src/Resources/OrganizationResource.php @@ -0,0 +1,37 @@ +connector->send(new GetOrganization($this->slug)); + + $data = $response->json('data'); + + if (! is_array($data)) { + throw new RuntimeException('Forge /orgs/{slug} response did not include a `data` object.'); + } + + return Organization::from($data); + } +} diff --git a/src/Resources/OrganizationsResource.php b/src/Resources/OrganizationsResource.php new file mode 100644 index 0000000..eb5f744 --- /dev/null +++ b/src/Resources/OrganizationsResource.php @@ -0,0 +1,97 @@ + + * + * @throws \Throwable + */ + public function all(?ListOrganizationsOptions $options = null): Page + { + return $this->parsePage( + $this->connector->send(new GetOrganizations($options)), + ); + } + + /** + * @return Generator + * + * @throws \Throwable + */ + public function iterate(?ListOrganizationsOptions $options = null): Generator + { + $options ??= new ListOrganizationsOptions; + + do { + $page = $this->all($options); + + foreach ($page as $organization) { + yield $organization; + } + + $options = new ListOrganizationsOptions( + size: $options->size, + cursor: $page->nextCursor, + ); + } while ($page->hasMore()); + } + + /** + * @return Page + */ + private function parsePage(Response $response): Page + { + $data = $response->json('data'); + $meta = $response->json('meta'); + + $items = []; + if (is_array($data)) { + foreach ($data as $entry) { + if (is_array($entry)) { + $items[] = Organization::from($entry); + } + } + } + + $nextCursor = null; + $prevCursor = null; + $size = count($items); + + if (is_array($meta)) { + $rawNext = $meta['next_cursor'] ?? null; + if (is_string($rawNext)) { + $nextCursor = $rawNext; + } + + $rawPrev = $meta['prev_cursor'] ?? null; + if (is_string($rawPrev)) { + $prevCursor = $rawPrev; + } + + $rawSize = $meta['per_page'] ?? null; + if (is_int($rawSize)) { + $size = $rawSize; + } + } + + return new Page( + data: $items, + nextCursor: $nextCursor, + prevCursor: $prevCursor, + size: $size, + ); + } +} diff --git a/tests/Fixtures/Saloon/organizations/get.json b/tests/Fixtures/Saloon/organizations/get.json new file mode 100644 index 0000000..c3443a4 --- /dev/null +++ b/tests/Fixtures/Saloon/organizations/get.json @@ -0,0 +1,27 @@ +{ + "statusCode": 200, + "headers": { + "Date": "REDACTED", + "Content-Type": "application\/vnd.api+json", + "Transfer-Encoding": "chunked", + "Connection": "keep-alive", + "Server": "REDACTED", + "Cache-Control": "no-cache, private", + "access-control-allow-origin": "*", + "x-ratelimit-limit": "REDACTED", + "x-ratelimit-remaining": "REDACTED", + "x-frame-options": [ + "SAMEORIGIN", + "SAMEORIGIN" + ], + "x-xss-protection": "1; mode=block", + "x-content-type-options": "nosniff", + "cf-cache-status": "REDACTED", + "set-cookie": "REDACTED", + "Strict-Transport-Security": "max-age=31536000; preload", + "CF-RAY": "REDACTED", + "alt-svc": "REDACTED" + }, + "data": "{\"data\":{\"id\":\"1\",\"type\":\"organizations\",\"attributes\":{\"name\":\"Test User\",\"slug\":\"test-org-1\",\"created_at\":\"2024-01-01T00:00:00.000000Z\",\"updated_at\":\"2024-01-01T00:00:00.000000Z\"},\"links\":{\"self\":{\"href\":\"https:\\\/\\\/forge.laravel.com\\\/api\\\/orgs\\\/test-org-1\"}}}}", + "context": [] +} \ No newline at end of file diff --git a/tests/Fixtures/Saloon/organizations/list.json b/tests/Fixtures/Saloon/organizations/list.json new file mode 100644 index 0000000..245c092 --- /dev/null +++ b/tests/Fixtures/Saloon/organizations/list.json @@ -0,0 +1,27 @@ +{ + "statusCode": 200, + "headers": { + "Date": "REDACTED", + "Content-Type": "application\/vnd.api+json", + "Transfer-Encoding": "chunked", + "Connection": "keep-alive", + "Server": "REDACTED", + "Cache-Control": "no-cache, private", + "access-control-allow-origin": "*", + "x-ratelimit-limit": "REDACTED", + "x-ratelimit-remaining": "REDACTED", + "x-frame-options": [ + "SAMEORIGIN", + "SAMEORIGIN" + ], + "x-xss-protection": "1; mode=block", + "x-content-type-options": "nosniff", + "cf-cache-status": "REDACTED", + "set-cookie": "REDACTED", + "Strict-Transport-Security": "max-age=31536000; preload", + "CF-RAY": "REDACTED", + "alt-svc": "REDACTED" + }, + "data": "{\"data\":[{\"id\":\"1\",\"type\":\"organizations\",\"attributes\":{\"name\":\"Test User\",\"slug\":\"test-org-1\",\"created_at\":\"2024-01-01T00:00:00.000000Z\",\"updated_at\":\"2024-01-01T00:00:00.000000Z\"},\"links\":{\"self\":{\"href\":\"https:\\\/\\\/forge.laravel.com\\\/api\\\/orgs\\\/test-org-1\"}}},{\"id\":\"1\",\"type\":\"organizations\",\"attributes\":{\"name\":\"Test User\",\"slug\":\"test-org-2\",\"created_at\":\"2024-01-01T00:00:00.000000Z\",\"updated_at\":\"2024-01-01T00:00:00.000000Z\"},\"links\":{\"self\":{\"href\":\"https:\\\/\\\/forge.laravel.com\\\/api\\\/orgs\\\/test-org-2\"}}}],\"links\":[],\"meta\":{\"path\":\"https:\\\/\\\/forge.laravel.com\\\/api\\\/orgs\",\"per_page\":30,\"next_cursor\":null,\"prev_cursor\":null}}", + "context": [] +} \ No newline at end of file diff --git a/tests/Unit/Data/ListOrganizationsOptionsTest.php b/tests/Unit/Data/ListOrganizationsOptionsTest.php new file mode 100644 index 0000000..0e78ed2 --- /dev/null +++ b/tests/Unit/Data/ListOrganizationsOptionsTest.php @@ -0,0 +1,38 @@ +toQuery())->toBe([]); + }); + +test('toQuery() emits page[size] when size is set', + function (): void { + $options = new ListOrganizationsOptions(size: 50); + + expect($options->toQuery())->toBe(['page[size]' => 50]); + }); + +test('toQuery() emits page[cursor] when cursor is set', + function (): void { + $options = new ListOrganizationsOptions(cursor: 'abc123'); + + expect($options->toQuery())->toBe(['page[cursor]' => 'abc123']); + }); + +test('toQuery() emits both size and cursor when set together', + function (): void { + $options = new ListOrganizationsOptions(size: 10, cursor: 'xyz'); + + expect($options->toQuery())->toBe([ + 'page[size]' => 10, + 'page[cursor]' => 'xyz', + ]); + }); diff --git a/tests/Unit/Data/OrganizationTest.php b/tests/Unit/Data/OrganizationTest.php new file mode 100644 index 0000000..aaaef48 --- /dev/null +++ b/tests/Unit/Data/OrganizationTest.php @@ -0,0 +1,102 @@ + '42', + 'type' => 'organizations', + 'attributes' => [ + 'name' => 'Acme Inc.', + 'slug' => 'acme', + 'created_at' => '2024-05-14T12:00:00Z', + 'updated_at' => '2024-05-15T08:30:00Z', + ], + 'links' => ['self' => ['href' => 'https://forge.laravel.com/api/orgs/acme']], + ]); + + expect($organization->id)->toBe('42') + ->and($organization->name)->toBe('Acme Inc.') + ->and($organization->slug)->toBe('acme') + ->and($organization->createdAt)->toBeInstanceOf(DateTimeImmutable::class) + ->and($organization->createdAt?->format('c'))->toBe('2024-05-14T12:00:00+00:00') + ->and($organization->updatedAt)->toBeInstanceOf(DateTimeImmutable::class); + }); + +test('::from() leaves created_at and updated_at null when the spec sends null', + function (): void { + $organization = Organization::from([ + 'id' => '1', + 'attributes' => [ + 'name' => 'Bare', + 'slug' => 'bare', + 'created_at' => null, + 'updated_at' => null, + ], + ]); + + expect($organization->createdAt)->toBeNull() + ->and($organization->updatedAt)->toBeNull(); + }); + +test('::from() throws when id is missing', + function (): void { + Organization::from(['attributes' => ['name' => 'X', 'slug' => 'x']]); + })->throws(InvalidArgumentException::class, 'missing the `id` field'); + +test('::from() throws when attributes is missing', + function (): void { + Organization::from(['id' => '1']); + })->throws(InvalidArgumentException::class, 'missing the `attributes` object'); + +test('::from() throws when attributes.name is not a string', + function (): void { + Organization::from(['id' => '1', 'attributes' => ['slug' => 'x', 'name' => 123]]); + })->throws(InvalidArgumentException::class, '`attributes.name` must be a string'); + +test('::from() throws when attributes.slug is not a string', + function (): void { + Organization::from(['id' => '1', 'attributes' => ['name' => 'X']]); + })->throws(InvalidArgumentException::class, '`attributes.slug` must be a string'); + +test('::from() throws when created_at is the wrong type', + function (): void { + Organization::from([ + 'id' => '1', + 'attributes' => ['name' => 'X', 'slug' => 'x', 'created_at' => 12345], + ]); + })->throws(InvalidArgumentException::class, '`attributes.created_at` must be a string or null'); + +test('::from() throws when created_at is an unparseable string', + function (): void { + Organization::from([ + 'id' => '1', + 'attributes' => ['name' => 'X', 'slug' => 'x', 'created_at' => 'not-a-date'], + ]); + })->throws(InvalidArgumentException::class, '`attributes.created_at` is not a valid date-time'); + +test('jsonSerialize() emits the JSON:API attribute names', + function (): void { + $organization = new Organization( + id: '1', + name: 'Acme', + slug: 'acme', + createdAt: new DateTimeImmutable('2024-05-14T12:00:00Z'), + updatedAt: null, + ); + + expect($organization->jsonSerialize())->toBe([ + 'id' => '1', + 'name' => 'Acme', + 'slug' => 'acme', + 'created_at' => '2024-05-14T12:00:00+00:00', + 'updated_at' => null, + ]); + }); diff --git a/tests/Unit/Data/PageTest.php b/tests/Unit/Data/PageTest.php new file mode 100644 index 0000000..717fc5e --- /dev/null +++ b/tests/Unit/Data/PageTest.php @@ -0,0 +1,77 @@ +data)->toBe(['a', 'b', 'c']) + ->and($page->nextCursor)->toBe('next-123') + ->and($page->prevCursor)->toBe('prev-456') + ->and($page->size)->toBe(3); + }); + +test('hasMore() is true when nextCursor is set', + function (): void { + $page = new Page(data: ['a'], nextCursor: 'next', prevCursor: null, size: 1); + + expect($page->hasMore())->toBeTrue(); + }); + +test('hasMore() is false when nextCursor is null', + function (): void { + $page = new Page(data: ['a'], nextCursor: null, prevCursor: null, size: 1); + + expect($page->hasMore())->toBeFalse(); + }); + +test('iterates over the data items', + function (): void { + $page = new Page(data: ['x', 'y', 'z'], nextCursor: null, prevCursor: null, size: 3); + + $collected = []; + foreach ($page as $item) { + $collected[] = $item; + } + + expect($collected)->toBe(['x', 'y', 'z']); + }); + +test('iterates over an empty page without yielding anything', + function (): void { + $page = new Page(data: [], nextCursor: null, prevCursor: null, size: 0); + + $collected = []; + foreach ($page as $item) { + $collected[] = $item; + } + + expect($collected)->toBe([]); + }); + +test('jsonSerialize() emits the snake-case shape', + function (): void { + $page = new Page( + data: ['a', 'b'], + nextCursor: 'next', + prevCursor: 'prev', + size: 2, + ); + + expect($page->jsonSerialize())->toBe([ + 'data' => ['a', 'b'], + 'next_cursor' => 'next', + 'prev_cursor' => 'prev', + 'size' => 2, + ]); + }); diff --git a/tests/Unit/Exceptions/RateLimitExceptionTest.php b/tests/Unit/Exceptions/RateLimitExceptionTest.php new file mode 100644 index 0000000..4fe5442 --- /dev/null +++ b/tests/Unit/Exceptions/RateLimitExceptionTest.php @@ -0,0 +1,42 @@ + new ForgeFixture('me/me-429'), + ]); + $forge = new Forge('test-token')->withMockClient($mockClient); + + expect(fn (): User => $forge->me())->toThrow(function (RateLimitException $rateLimitException): void { + expect($rateLimitException->retryAfter())->toBe(60); + }); + }); + +test('retryAfter() returns null when the header is missing', + /** + * @throws Throwable + */ + function (): void { + $mockClient = new MockClient([ + GetMe::class => new ForgeFixture('me/me-429-no-header'), + ]); + $forge = new Forge('test-token')->withMockClient($mockClient); + + expect(fn (): User => $forge->me())->toThrow(function (RateLimitException $rateLimitException): void { + expect($rateLimitException->retryAfter())->toBeNull(); + }); + }); diff --git a/tests/Unit/Exceptions/ValidationExceptionTest.php b/tests/Unit/Exceptions/ValidationExceptionTest.php new file mode 100644 index 0000000..c014c15 --- /dev/null +++ b/tests/Unit/Exceptions/ValidationExceptionTest.php @@ -0,0 +1,66 @@ + new ForgeFixture('me/me-422'), + ]); + $forge = new Forge('test-token')->withMockClient($mockClient); + + expect(fn (): User => $forge->me())->toThrow(function (ValidationException $validationException): void { + expect($validationException->errors())->toHaveKey('name') + ->and($validationException->errors())->toHaveKey('email') + ->and($validationException->errorsFor('name'))->toBe([ + 'The name field is required.', + 'The name must be at least 3 characters.', + ]) + ->and($validationException->firstError('name'))->toBe('The name field is required.') + ->and($validationException->firstError('nonexistent'))->toBeNull(); + }); + }); + +test('errors() returns an empty array when no errors key is present', + /** + * @throws Throwable + */ + function (): void { + $mockClient = new MockClient([ + GetMe::class => new ForgeFixture('me/me-422-empty'), + ]); + $forge = new Forge('test-token')->withMockClient($mockClient); + + expect(fn (): User => $forge->me())->toThrow(function (ValidationException $validationException): void { + expect($validationException->errors())->toBe([]) + ->and($validationException->errorsFor('anything'))->toBe([]) + ->and($validationException->firstError('anything'))->toBeNull(); + }); + }); + +test('errors() skips entries with non-string keys or non-array messages', + /** + * @throws Throwable + */ + function (): void { + $mockClient = new MockClient([ + GetMe::class => new ForgeFixture('me/me-422-bad-shape'), + ]); + $forge = new Forge('test-token')->withMockClient($mockClient); + + expect(fn (): User => $forge->me())->toThrow(function (ValidationException $validationException): void { + expect($validationException->errors())->toBe([]); + }); + }); diff --git a/tests/Unit/ForgeTest.php b/tests/Unit/ForgeTest.php index 915c5bc..0a45607 100644 --- a/tests/Unit/ForgeTest.php +++ b/tests/Unit/ForgeTest.php @@ -5,34 +5,9 @@ namespace Tests\Unit; use InvalidArgumentException; -use PhpDevKits\ForgeSdk\Data\User; -use PhpDevKits\ForgeSdk\Exceptions\ApiException; -use PhpDevKits\ForgeSdk\Exceptions\BadRequestException; -use PhpDevKits\ForgeSdk\Exceptions\ConnectionException; -use PhpDevKits\ForgeSdk\Exceptions\ForbiddenException; -use PhpDevKits\ForgeSdk\Exceptions\NotFoundException; -use PhpDevKits\ForgeSdk\Exceptions\RateLimitException; -use PhpDevKits\ForgeSdk\Exceptions\ServerException; -use PhpDevKits\ForgeSdk\Exceptions\UnauthorizedException; -use PhpDevKits\ForgeSdk\Exceptions\ValidationException; use PhpDevKits\ForgeSdk\Forge; -use PhpDevKits\ForgeSdk\Requests\Me\GetMe; -use RuntimeException; -use Saloon\Exceptions\Request\FatalRequestException; -use Saloon\Http\Faking\MockClient; -use Saloon\Http\Faking\MockResponse; -use Saloon\Http\PendingRequest; -use Tests\Utils\ForgeFixture; - -beforeEach(function (): void { - $token = ($_ENV['FORGE_TEST_TOKEN'] ?? '') ?: 'test-token'; - - $this->mockClient = new MockClient([ - GetMe::class => new ForgeFixture('me/me'), - ]); - - $this->forge = new Forge($token)->withMockClient($this->mockClient); -}); +use PhpDevKits\ForgeSdk\Resources\OrganizationResource; +use PhpDevKits\ForgeSdk\Resources\OrganizationsResource; afterEach(function (): void { unset( @@ -48,129 +23,49 @@ } }); -// --------------------------------------------------------------------------- -// me() -// --------------------------------------------------------------------------- - -test('returns the authenticated user from /me', - /** - * @throws Throwable - */ +test('constructor captures the default organization slug', function (): void { - $user = $this->forge->me(); + $forge = new Forge('test-token', 'acme'); - expect($user)->toBeInstanceOf(User::class) - ->and($user->id)->toBeString()->not->toBeEmpty() - ->and($user->name)->toBeString()->not->toBeEmpty() - ->and($user->email)->toBeString()->toContain('@'); + expect($forge->defaultOrganization)->toBe('acme'); }); -test('sends the /me request to the forge.laravel.com/api base URL', - /** - * @throws Throwable - */ +test('constructor leaves defaultOrganization null when no slug is given', function (): void { - $this->forge->me(); + $forge = new Forge('test-token'); - $url = $this->mockClient->getLastPendingRequest()?->getUrl() ?? ''; - - expect($url)->toBe('https://forge.laravel.com/api/me'); + expect($forge->defaultOrganization)->toBeNull(); }); -test('sends the bearer token in the Authorization header', - /** - * @throws Throwable - */ - function (): void { - $forge = new Forge('secret-pat-123')->withMockClient($this->mockClient); - - $forge->me(); - - $headers = $this->mockClient->getLastPendingRequest()?->headers(); - - expect($headers?->get('Authorization'))->toBe('Bearer secret-pat-123'); - }); - -test('negotiates JSON:API content type', - /** - * @throws Throwable - */ - function (): void { - $this->forge->me(); - - $headers = $this->mockClient->getLastPendingRequest()?->headers(); - - expect($headers?->get('Accept'))->toBe('application/vnd.api+json') - ->and($headers?->get('Content-Type'))->toBe('application/vnd.api+json'); - }); - -test('throws when the /me response has no data object', - /** - * @throws Throwable - */ - function (): void { - $mockClient = new MockClient([ - GetMe::class => new ForgeFixture('me/me-missing-data'), - ]); - $forge = new Forge('test-token')->withMockClient($mockClient); - - $forge->me(); - })->throws(RuntimeException::class, 'Forge /me response did not include a `data` object.'); - -// --------------------------------------------------------------------------- -// fromEnvironment() -// --------------------------------------------------------------------------- - test('Forge::fromEnvironment() reads the token from FORGE_TOKEN', - /** - * @throws Throwable - */ function (): void { $_ENV['FORGE_TOKEN'] = 'env-token-abc'; $forge = Forge::fromEnvironment(); - $mockClient = new MockClient([ - GetMe::class => new ForgeFixture('me/me'), - ]); - $forge->withMockClient($mockClient); - - $forge->me(); - - $headers = $mockClient->getLastPendingRequest()?->headers(); - - expect($headers?->get('Authorization'))->toBe('Bearer env-token-abc'); + expect($forge)->toBeInstanceOf(Forge::class); }); test('Forge::fromEnvironment() captures FORGE_ORGANIZATION when set', - /** - * @throws Throwable - */ function (): void { $_ENV['FORGE_TOKEN'] = 'env-token-abc'; $_ENV['FORGE_ORGANIZATION'] = 'acme'; $forge = Forge::fromEnvironment(); - expect($forge->organization)->toBe('acme'); + expect($forge->defaultOrganization)->toBe('acme'); }); -test('Forge::fromEnvironment() leaves organization null when FORGE_ORGANIZATION is unset', - /** - * @throws Throwable - */ +test('Forge::fromEnvironment() leaves defaultOrganization null when FORGE_ORGANIZATION is unset', function (): void { $_ENV['FORGE_TOKEN'] = 'env-token-abc'; $forge = Forge::fromEnvironment(); - expect($forge->organization)->toBeNull(); + expect($forge->defaultOrganization)->toBeNull(); }); test('Forge::fromEnvironment() throws when FORGE_TOKEN is missing', - /** - * @throws InvalidArgumentException - */ function (): void { unset($_ENV['FORGE_TOKEN']); @@ -178,90 +73,52 @@ function (): void { })->throws(InvalidArgumentException::class, 'FORGE_TOKEN environment variable is required.'); test('Forge::fromEnvironment() throws when FORGE_TOKEN is an empty string', - /** - * @throws InvalidArgumentException - */ function (): void { $_ENV['FORGE_TOKEN'] = ''; Forge::fromEnvironment(); })->throws(InvalidArgumentException::class, 'FORGE_TOKEN environment variable is required.'); -// --------------------------------------------------------------------------- -// fromConfig() -// --------------------------------------------------------------------------- - test('Forge::fromConfig() reads the token from a JSON file at the given path', - /** - * @throws Throwable - */ function (): void { $configPath = sys_get_temp_dir().'/forge-test-config.json'; file_put_contents($configPath, json_encode(['token' => 'config-token-xyz'])); $forge = Forge::fromConfig($configPath); - $mockClient = new MockClient([ - GetMe::class => new ForgeFixture('me/me'), - ]); - $forge->withMockClient($mockClient); - $forge->me(); - - $headers = $mockClient->getLastPendingRequest()?->headers(); - - expect($headers?->get('Authorization'))->toBe('Bearer config-token-xyz'); + expect($forge)->toBeInstanceOf(Forge::class); }); test('Forge::fromConfig() captures organization from JSON', - /** - * @throws Throwable - */ function (): void { $configPath = sys_get_temp_dir().'/forge-test-config.json'; file_put_contents($configPath, json_encode(['token' => 'abc', 'organization' => 'acme'])); $forge = Forge::fromConfig($configPath); - expect($forge->organization)->toBe('acme'); + expect($forge->defaultOrganization)->toBe('acme'); }); -test('Forge::fromConfig() leaves organization null when absent from JSON', - /** - * @throws Throwable - */ +test('Forge::fromConfig() leaves defaultOrganization null when absent from JSON', function (): void { $configPath = sys_get_temp_dir().'/forge-test-config.json'; file_put_contents($configPath, json_encode(['token' => 'abc'])); $forge = Forge::fromConfig($configPath); - expect($forge->organization)->toBeNull(); + expect($forge->defaultOrganization)->toBeNull(); }); test('Forge::fromConfig() defaults to ./forge.json when no path is given', - /** - * @throws Throwable - */ function (): void { file_put_contents(getcwd().'/forge.json', json_encode(['token' => 'cwd-token'])); $forge = Forge::fromConfig(); - $mockClient = new MockClient([ - GetMe::class => new ForgeFixture('me/me'), - ]); - $forge->withMockClient($mockClient); - $forge->me(); - - $headers = $mockClient->getLastPendingRequest()?->headers(); - - expect($headers?->get('Authorization'))->toBe('Bearer cwd-token'); + expect($forge)->toBeInstanceOf(Forge::class); }); test('Forge::fromConfig() honors FORGE_CONFIG_PATH when no path is given', - /** - * @throws Throwable - */ function (): void { $configPath = sys_get_temp_dir().'/forge-test-config.json'; file_put_contents($configPath, json_encode(['token' => 'env-path-token'])); @@ -269,21 +126,10 @@ function (): void { $forge = Forge::fromConfig(); - $mockClient = new MockClient([ - GetMe::class => new ForgeFixture('me/me'), - ]); - $forge->withMockClient($mockClient); - $forge->me(); - - $headers = $mockClient->getLastPendingRequest()?->headers(); - - expect($headers?->get('Authorization'))->toBe('Bearer env-path-token'); + expect($forge)->toBeInstanceOf(Forge::class); }); test('Forge::fromConfig() throws when the file is missing', - /** - * @throws InvalidArgumentException - */ function (): void { $missingPath = sys_get_temp_dir().'/forge-test-config.json'; @@ -291,9 +137,6 @@ function (): void { })->throws(InvalidArgumentException::class, 'Forge config file not found'); test('Forge::fromConfig() throws when the file is not valid JSON', - /** - * @throws InvalidArgumentException - */ function (): void { $configPath = sys_get_temp_dir().'/forge-test-config.json'; file_put_contents($configPath, '{not valid json'); @@ -302,9 +145,6 @@ function (): void { })->throws(InvalidArgumentException::class, 'is not valid JSON'); test('Forge::fromConfig() throws when the token key is missing', - /** - * @throws InvalidArgumentException - */ function (): void { $configPath = sys_get_temp_dir().'/forge-test-config.json'; file_put_contents($configPath, json_encode(['organization' => 'acme'])); @@ -313,9 +153,6 @@ function (): void { })->throws(InvalidArgumentException::class, 'missing the required `token` key'); test('Forge::fromConfig() throws when the JSON root is not an object', - /** - * @throws InvalidArgumentException - */ function (): void { $configPath = sys_get_temp_dir().'/forge-test-config.json'; file_put_contents($configPath, json_encode('a bare string, not an object')); @@ -323,240 +160,16 @@ function (): void { Forge::fromConfig($configPath); })->throws(InvalidArgumentException::class, 'must contain a JSON object'); -// --------------------------------------------------------------------------- -// Status -> exception mapping -// --------------------------------------------------------------------------- - -test('throws BadRequestException on a 400 response', - /** - * @throws Throwable - */ - function (): void { - $mockClient = new MockClient([ - GetMe::class => new ForgeFixture('me/me-400'), - ]); - $forge = new Forge('test-token')->withMockClient($mockClient); - - $forge->me(); - })->throws(BadRequestException::class); - -test('throws UnauthorizedException on a 401 response', - /** - * @throws Throwable - */ - function (): void { - $mockClient = new MockClient([ - GetMe::class => new ForgeFixture('me/me-401'), - ]); - $forge = new Forge('bad-token')->withMockClient($mockClient); - - $forge->me(); - })->throws(UnauthorizedException::class); - -test('throws ForbiddenException on a 403 response', - /** - * @throws Throwable - */ - function (): void { - $mockClient = new MockClient([ - GetMe::class => new ForgeFixture('me/me-403'), - ]); - $forge = new Forge('test-token')->withMockClient($mockClient); - - $forge->me(); - })->throws(ForbiddenException::class); - -test('throws NotFoundException on a 404 response', - /** - * @throws Throwable - */ +test('organizations() returns an OrganizationsResource', function (): void { - $mockClient = new MockClient([ - GetMe::class => new ForgeFixture('me/me-404'), - ]); - $forge = new Forge('test-token')->withMockClient($mockClient); - - $forge->me(); - })->throws(NotFoundException::class); - -test('throws ValidationException on a 422 response', - /** - * @throws Throwable - */ - function (): void { - $mockClient = new MockClient([ - GetMe::class => new ForgeFixture('me/me-422'), - ]); - $forge = new Forge('test-token')->withMockClient($mockClient); - - $forge->me(); - })->throws(ValidationException::class); - -test('ValidationException exposes the parsed Laravel error bag', - /** - * @throws Throwable - */ - function (): void { - $mockClient = new MockClient([ - GetMe::class => new ForgeFixture('me/me-422'), - ]); - $forge = new Forge('test-token')->withMockClient($mockClient); - - expect(fn (): User => $forge->me())->toThrow(function (ValidationException $validationException): void { - expect($validationException->errors())->toHaveKey('name') - ->and($validationException->errors())->toHaveKey('email') - ->and($validationException->errorsFor('name'))->toBe([ - 'The name field is required.', - 'The name must be at least 3 characters.', - ]) - ->and($validationException->firstError('name'))->toBe('The name field is required.') - ->and($validationException->firstError('nonexistent'))->toBeNull(); - }); - }); + $forge = new Forge('test-token'); -test('ValidationException::errors() returns an empty array when no errors key is present', - /** - * @throws Throwable - */ - function (): void { - $mockClient = new MockClient([ - GetMe::class => new ForgeFixture('me/me-422-empty'), - ]); - $forge = new Forge('test-token')->withMockClient($mockClient); - - expect(fn (): User => $forge->me())->toThrow(function (ValidationException $validationException): void { - expect($validationException->errors())->toBe([]) - ->and($validationException->errorsFor('anything'))->toBe([]) - ->and($validationException->firstError('anything'))->toBeNull(); - }); + expect($forge->organizations())->toBeInstanceOf(OrganizationsResource::class); }); -test('ValidationException::errors() skips entries with non-string keys or non-array messages', - /** - * @throws Throwable - */ +test('organization($slug) returns an OrganizationResource', function (): void { - $mockClient = new MockClient([ - GetMe::class => new ForgeFixture('me/me-422-bad-shape'), - ]); - $forge = new Forge('test-token')->withMockClient($mockClient); - - expect(fn (): User => $forge->me())->toThrow(function (ValidationException $validationException): void { - expect($validationException->errors())->toBe([]); - }); - }); + $forge = new Forge('test-token'); -test('throws RateLimitException on a 429 response', - /** - * @throws Throwable - */ - function (): void { - $mockClient = new MockClient([ - GetMe::class => new ForgeFixture('me/me-429'), - ]); - $forge = new Forge('test-token')->withMockClient($mockClient); - - $forge->me(); - })->throws(RateLimitException::class); - -test('RateLimitException parses the Retry-After header', - /** - * @throws Throwable - */ - function (): void { - $mockClient = new MockClient([ - GetMe::class => new ForgeFixture('me/me-429'), - ]); - $forge = new Forge('test-token')->withMockClient($mockClient); - - expect(fn (): User => $forge->me())->toThrow(function (RateLimitException $rateLimitException): void { - expect($rateLimitException->retryAfter())->toBe(60); - }); - }); - -test('RateLimitException::retryAfter() returns null when the header is missing', - /** - * @throws Throwable - */ - function (): void { - $mockClient = new MockClient([ - GetMe::class => new ForgeFixture('me/me-429-no-header'), - ]); - $forge = new Forge('test-token')->withMockClient($mockClient); - - expect(fn (): User => $forge->me())->toThrow(function (RateLimitException $rateLimitException): void { - expect($rateLimitException->retryAfter())->toBeNull(); - }); - }); - -test('throws ServerException on a 500 response', - /** - * @throws Throwable - */ - function (): void { - $mockClient = new MockClient([ - GetMe::class => new ForgeFixture('me/me-500'), - ]); - $forge = new Forge('test-token')->withMockClient($mockClient); - - $forge->me(); - })->throws(ServerException::class); - -test('throws ServerException on any 5xx response', - /** - * @throws Throwable - */ - function (): void { - $mockClient = new MockClient([ - GetMe::class => new ForgeFixture('me/me-503'), - ]); - $forge = new Forge('test-token')->withMockClient($mockClient); - - $forge->me(); - })->throws(ServerException::class); - -test('falls back to ApiException for an unknown status code', - /** - * @throws Throwable - */ - function (): void { - $mockClient = new MockClient([ - GetMe::class => new ForgeFixture('me/me-418'), - ]); - $forge = new Forge('test-token')->withMockClient($mockClient); - - $forge->me(); - })->throws(ApiException::class); - -test('ApiException::status() returns the response status code', - /** - * @throws Throwable - */ - function (): void { - $mockClient = new MockClient([ - GetMe::class => new ForgeFixture('me/me-404'), - ]); - $forge = new Forge('test-token')->withMockClient($mockClient); - - expect(fn (): User => $forge->me())->toThrow(function (NotFoundException $notFoundException): void { - expect($notFoundException->status())->toBe(404); - }); + expect($forge->organization('acme'))->toBeInstanceOf(OrganizationResource::class); }); - -test('wraps Saloon transport errors in ConnectionException', - /** - * @throws Throwable - */ - function (): void { - $mockClient = new MockClient([ - GetMe::class => MockResponse::make()->throw( - fn (PendingRequest $pendingRequest): FatalRequestException => new FatalRequestException( - new RuntimeException('Connection refused'), - $pendingRequest, - ), - ), - ]); - $forge = new Forge('test-token')->withMockClient($mockClient); - - $forge->me(); - })->throws(ConnectionException::class, 'Forge API connection failed'); diff --git a/tests/Unit/Requests/Me/GetMeTest.php b/tests/Unit/Requests/Me/GetMeTest.php new file mode 100644 index 0000000..af49aa8 --- /dev/null +++ b/tests/Unit/Requests/Me/GetMeTest.php @@ -0,0 +1,247 @@ +mockClient = new MockClient([ + GetMe::class => new ForgeFixture('me/me'), + ]); + + $this->forge = new Forge('test-token')->withMockClient($this->mockClient); +}); + +test('returns the authenticated user', + /** + * @throws Throwable + */ + function (): void { + $user = $this->forge->me(); + + expect($user)->toBeInstanceOf(User::class) + ->and($user->id)->toBeString()->not->toBeEmpty() + ->and($user->name)->toBeString()->not->toBeEmpty() + ->and($user->email)->toBeString()->toContain('@'); + }); + +test('sends to the forge.laravel.com/api base URL', + /** + * @throws Throwable + */ + function (): void { + $this->forge->me(); + + $url = $this->mockClient->getLastPendingRequest()?->getUrl() ?? ''; + + expect($url)->toBe('https://forge.laravel.com/api/me'); + }); + +test('sends the bearer token in the Authorization header', + /** + * @throws Throwable + */ + function (): void { + $forge = new Forge('secret-pat-123')->withMockClient($this->mockClient); + + $forge->me(); + + $headers = $this->mockClient->getLastPendingRequest()?->headers(); + + expect($headers?->get('Authorization'))->toBe('Bearer secret-pat-123'); + }); + +test('negotiates JSON:API content type', + /** + * @throws Throwable + */ + function (): void { + $this->forge->me(); + + $headers = $this->mockClient->getLastPendingRequest()?->headers(); + + expect($headers?->get('Accept'))->toBe('application/vnd.api+json') + ->and($headers?->get('Content-Type'))->toBe('application/vnd.api+json'); + }); + +test('throws when the response has no data object', + /** + * @throws Throwable + */ + function (): void { + $mockClient = new MockClient([ + GetMe::class => new ForgeFixture('me/me-missing-data'), + ]); + $forge = new Forge('test-token')->withMockClient($mockClient); + + $forge->me(); + })->throws(RuntimeException::class, 'Forge /me response did not include a `data` object.'); + +test('throws BadRequestException on a 400 response', + /** + * @throws Throwable + */ + function (): void { + $mockClient = new MockClient([ + GetMe::class => new ForgeFixture('me/me-400'), + ]); + $forge = new Forge('test-token')->withMockClient($mockClient); + + $forge->me(); + })->throws(BadRequestException::class); + +test('throws UnauthorizedException on a 401 response', + /** + * @throws Throwable + */ + function (): void { + $mockClient = new MockClient([ + GetMe::class => new ForgeFixture('me/me-401'), + ]); + $forge = new Forge('bad-token')->withMockClient($mockClient); + + $forge->me(); + })->throws(UnauthorizedException::class); + +test('throws ForbiddenException on a 403 response', + /** + * @throws Throwable + */ + function (): void { + $mockClient = new MockClient([ + GetMe::class => new ForgeFixture('me/me-403'), + ]); + $forge = new Forge('test-token')->withMockClient($mockClient); + + $forge->me(); + })->throws(ForbiddenException::class); + +test('throws NotFoundException on a 404 response', + /** + * @throws Throwable + */ + function (): void { + $mockClient = new MockClient([ + GetMe::class => new ForgeFixture('me/me-404'), + ]); + $forge = new Forge('test-token')->withMockClient($mockClient); + + $forge->me(); + })->throws(NotFoundException::class); + +test('throws ValidationException on a 422 response', + /** + * @throws Throwable + */ + function (): void { + $mockClient = new MockClient([ + GetMe::class => new ForgeFixture('me/me-422'), + ]); + $forge = new Forge('test-token')->withMockClient($mockClient); + + $forge->me(); + })->throws(ValidationException::class); + +test('throws RateLimitException on a 429 response', + /** + * @throws Throwable + */ + function (): void { + $mockClient = new MockClient([ + GetMe::class => new ForgeFixture('me/me-429'), + ]); + $forge = new Forge('test-token')->withMockClient($mockClient); + + $forge->me(); + })->throws(RateLimitException::class); + +test('throws ServerException on a 500 response', + /** + * @throws Throwable + */ + function (): void { + $mockClient = new MockClient([ + GetMe::class => new ForgeFixture('me/me-500'), + ]); + $forge = new Forge('test-token')->withMockClient($mockClient); + + $forge->me(); + })->throws(ServerException::class); + +test('throws ServerException on any 5xx response', + /** + * @throws Throwable + */ + function (): void { + $mockClient = new MockClient([ + GetMe::class => new ForgeFixture('me/me-503'), + ]); + $forge = new Forge('test-token')->withMockClient($mockClient); + + $forge->me(); + })->throws(ServerException::class); + +test('falls back to ApiException for an unknown status code', + /** + * @throws Throwable + */ + function (): void { + $mockClient = new MockClient([ + GetMe::class => new ForgeFixture('me/me-418'), + ]); + $forge = new Forge('test-token')->withMockClient($mockClient); + + $forge->me(); + })->throws(ApiException::class); + +test('ApiException::status() returns the response status code', + /** + * @throws Throwable + */ + function (): void { + $mockClient = new MockClient([ + GetMe::class => new ForgeFixture('me/me-404'), + ]); + $forge = new Forge('test-token')->withMockClient($mockClient); + + expect(fn (): User => $forge->me())->toThrow(function (NotFoundException $notFoundException): void { + expect($notFoundException->status())->toBe(404); + }); + }); + +test('wraps Saloon transport errors in ConnectionException', + /** + * @throws Throwable + */ + function (): void { + $mockClient = new MockClient([ + GetMe::class => MockResponse::make()->throw( + fn (PendingRequest $pendingRequest): FatalRequestException => new FatalRequestException( + new RuntimeException('Connection refused'), + $pendingRequest, + ), + ), + ]); + $forge = new Forge('test-token')->withMockClient($mockClient); + + $forge->me(); + })->throws(ConnectionException::class, 'Forge API connection failed'); diff --git a/tests/Unit/Requests/Organizations/GetOrganizationTest.php b/tests/Unit/Requests/Organizations/GetOrganizationTest.php new file mode 100644 index 0000000..0b47b24 --- /dev/null +++ b/tests/Unit/Requests/Organizations/GetOrganizationTest.php @@ -0,0 +1,30 @@ + new ForgeFixture('organizations/get'), + ]); + $forge = new Forge($token)->withMockClient($mockClient); + + $forge->organization($slug)->get(); + + $url = $mockClient->getLastPendingRequest()?->getUrl() ?? ''; + + expect($url)->toBe('https://forge.laravel.com/api/orgs/'.$slug); + }); diff --git a/tests/Unit/Requests/Organizations/GetOrganizationsTest.php b/tests/Unit/Requests/Organizations/GetOrganizationsTest.php new file mode 100644 index 0000000..e12929f --- /dev/null +++ b/tests/Unit/Requests/Organizations/GetOrganizationsTest.php @@ -0,0 +1,60 @@ +mockClient = new MockClient([ + GetOrganizations::class => new ForgeFixture('organizations/list'), + ]); + + $this->forge = new Forge($token)->withMockClient($this->mockClient); +}); + +test('sends a GET to /orgs', + /** + * @throws Throwable + */ + function (): void { + $this->forge->organizations()->all(); + + $url = $this->mockClient->getLastPendingRequest()?->getUrl() ?? ''; + + expect($url)->toStartWith('https://forge.laravel.com/api/orgs'); + }); + +test('sends no query params when called with no options', + /** + * @throws Throwable + */ + function (): void { + $this->forge->organizations()->all(); + + $url = $this->mockClient->getLastPendingRequest()?->getUrl() ?? ''; + + expect($url)->toBe('https://forge.laravel.com/api/orgs'); + }); + +test('emits page[size] and page[cursor] when ListOrganizationsOptions is supplied', + /** + * @throws Throwable + */ + function (): void { + $this->forge->organizations()->all(new ListOrganizationsOptions(size: 25, cursor: 'abc')); + + $query = $this->mockClient->getLastPendingRequest()?->query()->all() ?? []; + + expect($query)->toBe([ + 'page[size]' => 25, + 'page[cursor]' => 'abc', + ]); + }); diff --git a/tests/Unit/Resources/OrganizationResourceTest.php b/tests/Unit/Resources/OrganizationResourceTest.php new file mode 100644 index 0000000..c8982e9 --- /dev/null +++ b/tests/Unit/Resources/OrganizationResourceTest.php @@ -0,0 +1,48 @@ + new ForgeFixture('organizations/get'), + ]); + $token = ($_ENV['FORGE_TEST_TOKEN'] ?? '') ?: 'test-token'; + $forge = new Forge($token)->withMockClient($mockClient); + + $organization = $forge->organization($slug)->get(); + + expect($organization)->toBeInstanceOf(Organization::class) + ->and($organization->id)->toBeString()->not->toBeEmpty() + ->and($organization->slug)->toBeString()->not->toBeEmpty() + ->and($organization->name)->toBeString(); + }); + +test('get() throws when the response has no data object', + /** + * @throws Throwable + */ + function (): void { + $mockClient = new MockClient([ + GetOrganization::class => MockResponse::make(['meta' => []]), + ]); + $token = ($_ENV['FORGE_TEST_TOKEN'] ?? '') ?: 'test-token'; + $forge = new Forge($token)->withMockClient($mockClient); + + $forge->organization('acme')->get(); + })->throws(RuntimeException::class, 'Forge /orgs/{slug} response did not include a `data` object.'); diff --git a/tests/Unit/Resources/OrganizationsResourceTest.php b/tests/Unit/Resources/OrganizationsResourceTest.php new file mode 100644 index 0000000..d523d98 --- /dev/null +++ b/tests/Unit/Resources/OrganizationsResourceTest.php @@ -0,0 +1,118 @@ +', + /** + * @throws Throwable + */ + function (): void { + $mockClient = new MockClient([ + GetOrganizations::class => new ForgeFixture('organizations/list'), + ]); + $token = ($_ENV['FORGE_TEST_TOKEN'] ?? '') ?: 'test-token'; + $forge = new Forge($token)->withMockClient($mockClient); + + $page = $forge->organizations()->all(); + + expect($page)->toBeInstanceOf(Page::class) + ->and($page->data)->not->toBeEmpty() + ->and($page->data[0])->toBeInstanceOf(Organization::class); + }); + +test('all() exposes pagination metadata from the response', + /** + * @throws Throwable + */ + function (): void { + $mockClient = new MockClient([ + GetOrganizations::class => new ForgeFixture('organizations/list'), + ]); + $token = ($_ENV['FORGE_TEST_TOKEN'] ?? '') ?: 'test-token'; + $forge = new Forge($token)->withMockClient($mockClient); + + $page = $forge->organizations()->all(); + + expect($page->size)->toBeInt()->toBeGreaterThan(0); + }); + +test('iterate() walks across next_cursor pages and stops on null cursor', + /* + * Pagination control flow is tested with synthetic responses because real + * cursor values aren't known at fixture-record time. The shape of the + * response (data, meta.next_cursor) is exercised against real Forge data + * in the all() test above. + * + * @throws Throwable + */ + function (): void { + $orgAttrs = static fn (string $slug): array => [ + 'name' => $slug, + 'slug' => $slug, + 'created_at' => '2024-01-01T00:00:00Z', + 'updated_at' => '2024-01-01T00:00:00Z', + ]; + + $mockClient = new MockClient([ + MockResponse::make([ + 'data' => [ + ['id' => '1', 'type' => 'organizations', 'attributes' => $orgAttrs('first'), 'links' => ['self' => ['href' => 'x']]], + ], + 'meta' => ['path' => 'x', 'per_page' => 1, 'next_cursor' => 'CURSOR-A', 'prev_cursor' => null], + ]), + MockResponse::make([ + 'data' => [ + ['id' => '2', 'type' => 'organizations', 'attributes' => $orgAttrs('second'), 'links' => ['self' => ['href' => 'x']]], + ], + 'meta' => ['path' => 'x', 'per_page' => 1, 'next_cursor' => null, 'prev_cursor' => 'CURSOR-A'], + ]), + ]); + $token = ($_ENV['FORGE_TEST_TOKEN'] ?? '') ?: 'test-token'; + $forge = new Forge($token)->withMockClient($mockClient); + + $collected = []; + foreach ($forge->organizations()->iterate(new ListOrganizationsOptions(size: 1)) as $organization) { + $collected[] = $organization->slug; + } + + expect($collected)->toBe(['first', 'second']); + }); + +test('iterate() forwards the next_cursor on the second request', + /** + * @throws Throwable + */ + function (): void { + $mockClient = new MockClient([ + MockResponse::make([ + 'data' => [], + 'meta' => ['path' => 'x', 'per_page' => 0, 'next_cursor' => 'NEXT-PAGE-CURSOR', 'prev_cursor' => null], + ]), + MockResponse::make([ + 'data' => [], + 'meta' => ['path' => 'x', 'per_page' => 0, 'next_cursor' => null, 'prev_cursor' => 'NEXT-PAGE-CURSOR'], + ]), + ]); + $token = ($_ENV['FORGE_TEST_TOKEN'] ?? '') ?: 'test-token'; + $forge = new Forge($token)->withMockClient($mockClient); + + iterator_to_array($forge->organizations()->iterate(new ListOrganizationsOptions(size: 5))); + + $query = $mockClient->getLastPendingRequest()?->query()->all() ?? []; + + expect($query)->toBe([ + 'page[size]' => 5, + 'page[cursor]' => 'NEXT-PAGE-CURSOR', + ]); + }); diff --git a/tests/Utils/ForgeFixture.php b/tests/Utils/ForgeFixture.php index fdb66fe..868a4f0 100644 --- a/tests/Utils/ForgeFixture.php +++ b/tests/Utils/ForgeFixture.php @@ -14,7 +14,10 @@ * file on disk is already sanitized — no test-time wrapping needed. * * Header rules match case-insensitively; JSON key rules match by leaf key - * name anywhere in the body (not by dot-path). + * name anywhere in the body (not by dot-path). The regex pass covers slugs + * embedded in URL paths (`.../orgs/`), with its own sequential mapper + * that walks the body in the same order as the JSON-key pass — so the + * placeholder for a given real slug is consistent across both contexts. */ final class ForgeFixture extends Fixture { @@ -55,6 +58,57 @@ protected function defineSensitiveJsonParameters(): array 'email' => 'test@example.com', 'created_at' => '2024-01-01T00:00:00.000000Z', 'updated_at' => '2024-01-01T00:00:00.000000Z', + 'slug' => $this->sequentialPlaceholder('test-org-'), ]; } + + /** + * Regex pass for slugs embedded in URL paths (e.g. + * `https://forge.laravel.com/api/orgs/`). The JSON-key pass above + * has already replaced bare `slug` fields with placeholders; this pass + * brings URL paths into line so a reader of the fixture can't read off + * the real slug from `links.self.href`. + * + * @return array + */ + #[Override] + protected function defineSensitiveRegexPatterns(): array + { + $mapper = $this->sequentialPlaceholder('test-org-'); + + // Matches `/orgs/` whether the slashes are raw (`/`) or + // JSON-escaped (`\/`), since json_encode escapes forward slashes by + // default in the body string at this stage. + return [ + '#\\\\?/orgs\\\\?/[a-z0-9_-]+#i' => static function (string $match) use ($mapper): string { + $slashPos = strrpos($match, '/'); + $slug = substr($match, $slashPos + 1); + $prefix = substr($match, 0, $slashPos + 1); + + return $prefix.$mapper($slug); + }, + ]; + } + + /** + * Stable sequential anonymizer: distinct inputs get distinct outputs + * (`{prefix}1`, `{prefix}2`, ...), and the same input always maps to the + * same placeholder within a single record session. + */ + private function sequentialPlaceholder(string $prefix): callable + { + $map = []; + + return static function (mixed $value) use ($prefix, &$map): string { + if (! is_string($value)) { + return $prefix.'unknown'; + } + + if (! isset($map[$value])) { + $map[$value] = $prefix.(count($map) + 1); + } + + return $map[$value]; + }; + } }