feat: initial alpha release

This commit represents the complete foundational codebase for Nimbus Alpha, a Laravel package that provides an integrated, in-browser API client with automatic schema discovery from validation rules.

IMPORTANT: This is a squashed commit representing the culmination of extensive development, refactoring, and architectural iterations. All previous commit history has been intentionally removed to provide a clean foundation for the public alpha release.

The development of Nimbus involved:
- Multiple architectural refactorings
- Significant structural changes
- Experimental approaches that were later abandoned
- Learning iterations on the core concept
- Migration between different design patterns

This messy history would:
- Make git blame confusing and unhelpful
- Obscure the actual intent behind current implementation
- Create noise when reviewing changes
- Reference deleted or refactored code

If git blame brought you to this commit, it means you're looking at code that was part of the initial alpha release. Here's what to do:

1. Check Current Documentation
   - See `/wiki/contribution-guide/README.md` for architecture details
   - Review the specific module's README if available
   - Look for inline comments explaining the reasoning

2. Look for Related Code
   - Check other files in the same module
   - Look for tests that demonstrate intended behavior
   - Review interfaces and contracts

3. Context Matters
   - This code may have been updated since alpha
   - Check git log for subsequent changes to this file
   - Look for related issues or PRs on GitHub

---

This commit marks the beginning of Nimbus's public journey. All future
commits will build upon this foundation with clear, traceable history.

Thank you for using or contributing to Nimbus!
This commit is contained in:
Mazen Touati
2025-10-20 00:35:07 +02:00
commit c2aa6895d6
570 changed files with 54298 additions and 0 deletions

View File

@@ -0,0 +1,180 @@
<?php
namespace Sunchayn\Nimbus\Tests\App\Commands\Intellisense;
use Carbon\CarbonImmutable;
use Mockery;
use PHPUnit\Framework\Attributes\CoversClass;
use RuntimeException;
use Sunchayn\Nimbus\Commands\Intellisense\GenerateIntellisenseCommand;
use Sunchayn\Nimbus\IntellisenseProviders\Contracts\IntellisenseContract;
use Sunchayn\Nimbus\Tests\App\Commands\Intellisense\Stubs\Providers\FakeProviderOne;
use Sunchayn\Nimbus\Tests\App\Commands\Intellisense\Stubs\Providers\FakeProviderTwo;
use Sunchayn\Nimbus\Tests\TestCase;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\StringInput;
use Symfony\Component\Console\Output\OutputInterface;
#[CoversClass(GenerateIntellisenseCommand::class)]
class GenerateIntellisenseCommandFunctionalTest extends TestCase
{
private const GENERATED_BASE_PATH = __DIR__.'/../../../../resources/js/interfaces/generated';
protected function setUp(): void
{
parent::setUp();
CarbonImmutable::setTestNow(CarbonImmutable::now());
}
protected function tearDown(): void
{
@unlink(self::GENERATED_BASE_PATH.'/fake-provider-one.ts');
@unlink(self::GENERATED_BASE_PATH.'/fake-provider-two.ts');
parent::tearDown();
}
public function test_it_generates_intellisense_properly(): void
{
// Arrange
$command = resolve(GenerateIntellisenseCommand::class);
invade($command)->intellisenseProviders = [
FakeProviderOne::class,
FakeProviderTwo::class,
];
$input = Mockery::mock(StringInput::class)->makePartial();
$output = Mockery::mock(OutputInterface::class);
// <helper> Uncomment for debug
// $output->shouldReceive('writeln')->withAnyArgs()->andReturnUsing(fn ($values) => dump($values));
// Anticipate
$output->shouldReceive('write')->once();
$output->shouldReceive('writeln')->with("\n")->once();
$output->shouldReceive('writeln')->with(' >> <info>Generating TypeScript Intellisense...</info>')->once();
$output->shouldReceive('writeln')->with("\n> Generating fake-provider-one.ts")->once();
$output->shouldReceive('writeln')->with('<info>✓ Generated.</info>')->once();
$output->shouldReceive('writeln')->with("\n> Generating fake-provider-two.ts")->once();
$output->shouldReceive('writeln')->with('<info>✓ Generated.</info>')->once();
$output->shouldReceive('writeln')->with("\n<info>✓ All Intellisense generated successfully!</info>")->once();
// Act
$result = $command->run($input, $output);
// Assert
$this->assertEquals(Command::SUCCESS, $result);
$this->assertEquals(
$this->getExpectedContentForProviderOne(),
file_get_contents(self::GENERATED_BASE_PATH.'/fake-provider-one.ts'),
);
$this->assertEquals(
$this->getExpectedContentForProviderTwo(),
file_get_contents(self::GENERATED_BASE_PATH.'/fake-provider-two.ts'),
);
}
public function test_it_handles_intellisense_generation_failure(): void
{
// Arrange
$command = resolve(GenerateIntellisenseCommand::class);
$input = Mockery::mock(StringInput::class)->makePartial();
$output = Mockery::mock(OutputInterface::class);
$failingIntellisenseDummy = new class implements IntellisenseContract
{
public function getTargetFileName(): string
{
return 'test.ts';
}
public function generate(): string
{
throw new RuntimeException('Generation failed');
}
};
invade($command)->intellisenseProviders = [
$failingIntellisenseDummy::class,
];
// Anticipate
$output->shouldReceive('write')->once();
$output->shouldReceive('writeln')->with("\n")->once();
$output->shouldReceive('writeln')->with(' >> <info>Generating TypeScript Intellisense...</info>')->once();
$output->shouldReceive('writeln')->with("\n> Generating test.ts")->once();
$output->shouldReceive('writeln')->with('')->once();
$output->shouldReceive('writeln')->with('<error>Failed to generate:</error>.')->once();
$output->shouldReceive('writeln')->with('Generation failed')->once();
$output->shouldReceive('writeln')->with('')->once();
// Act
$result = $command->run($input, $output);
// Assert
$this->assertEquals(Command::FAILURE, $result);
}
/*
* Helpers.
*/
private function getExpectedContentForProviderOne(): string
{
$datetime = CarbonImmutable::now()->toIso8601String();
return <<<EXPECTED
/*
* This file is auto-generated.
* Don't update it manually, otherwise, your changes will be lost.
* To update the file run `php bin/intellisense`.
*
* Generated at: $datetime.
*/
type FakeProviderOne = string;
enum FakeOneValues {
foo = 'bar',
bar = 'baz',
}
EXPECTED;
}
private function getExpectedContentForProviderTwo(): string
{
$datetime = CarbonImmutable::now()->toIso8601String();
return <<<EXPECTED
/*
* This file is auto-generated.
* Don't update it manually, otherwise, your changes will be lost.
* To update the file run `php bin/intellisense`.
*
* Generated at: $datetime.
*/
enum FakeTwoValues {
foo = 'bar',
bar = 'baz',
}
type FakeProviderTwo = number;
EXPECTED;
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace Sunchayn\Nimbus\Tests\App\Commands\Intellisense\Stubs\Providers;
use Illuminate\Support\Str;
use RuntimeException;
use Sunchayn\Nimbus\IntellisenseProviders\Contracts\IntellisenseContract;
class FakeProviderOne implements IntellisenseContract
{
public function getStub(): string
{
return 'fake-provider-one.ts.stub';
}
public function getTargetFileName(): string
{
/** @phpstan-return non-empty-string */
return Str::remove('.stub', $this->getStub());
}
public function generate(): string
{
$enumCases = [];
foreach (['foo' => 'bar', 'bar' => 'baz'] as $key => $value) {
$enumCases[] = " {$key} = '{$value}',";
}
$enumContent = implode("\n", $enumCases);
return $this->replaceStubContent($enumContent);
}
private function replaceStubContent(string $enumList): string
{
$stubFile = file_get_contents(__DIR__.'/../'.$this->getStub()) ?: throw new RuntimeException('Cannot read stub file.');
return str_replace('{{ content }}', rtrim($enumList), $stubFile);
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace Sunchayn\Nimbus\Tests\App\Commands\Intellisense\Stubs\Providers;
class FakeProviderTwo extends FakeProviderOne
{
public function getStub(): string
{
return 'fake-provider-two.ts.stub';
}
}

View File

@@ -0,0 +1,5 @@
type FakeProviderOne = string;
enum FakeOneValues {
{{ content }}
}

View File

@@ -0,0 +1,5 @@
enum FakeTwoValues {
{{ content }}
}
type FakeProviderTwo = number;

View File

@@ -0,0 +1,217 @@
<?php
namespace Sunchayn\Nimbus\Tests\App\Modules\Relay\Actions;
use Carbon\CarbonImmutable;
use Illuminate\Cookie\CookieValuePrefix;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
use Mockery;
use Mockery\MockInterface;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\TestWith;
use Sunchayn\Nimbus\Modules\Relay\Actions\RequestRelayAction;
use Sunchayn\Nimbus\Modules\Relay\Authorization\AuthorizationCredentials;
use Sunchayn\Nimbus\Modules\Relay\Authorization\AuthorizationTypeEnum;
use Sunchayn\Nimbus\Modules\Relay\Authorization\Handlers\AuthorizationHandler;
use Sunchayn\Nimbus\Modules\Relay\Authorization\Handlers\AuthorizationHandlerFactory;
use Sunchayn\Nimbus\Modules\Relay\DataTransferObjects\RelayedRequestResponseData;
use Sunchayn\Nimbus\Modules\Relay\DataTransferObjects\RequestRelayData;
use Sunchayn\Nimbus\Modules\Relay\ValueObjects\ResponseCookieValueObject;
use Sunchayn\Nimbus\Tests\TestCase;
use Symfony\Component\HttpFoundation\ParameterBag;
#[CoversClass(RequestRelayAction::class)]
#[CoversClass(RequestRelayData::class)]
#[CoversClass(RelayedRequestResponseData::class)]
class RequestRelayActionFunctionalTest extends TestCase
{
private const ENDPOINT = 'https:://localhost/api/test-endpoint';
#[TestWith([200, 'OK'])]
#[TestWith([404, 'Not Found'])]
#[TestWith([301, 'Moved Permanently'])]
#[TestWith([500, 'Internal Server Error'])]
#[TestWith([419, 'Method Not Allowed'])]
public function test_it_relays_requests(
int $stubStatusCode,
string $expectedStatusText,
): void {
// Arrange
$requestData = new RequestRelayData(
method: Arr::random([
'POST',
'GET',
'PUT',
]),
endpoint: self::ENDPOINT,
authorization: $authorizationCredentials = $this->getRandomAuthorizationCredentials(),
headers: [
'Content-Type' => 'application/json',
'X-Custom-Header' => $customHeaderValue = uniqid(),
],
body: ['test' => 'data'],
cookies: new ParameterBag,
);
CarbonImmutable::setTestNow(CarbonImmutable::now());
$stubBody = ['message' => 'success', 'data' => ['test' => 'data']];
$encryptedCookieOriginalValue = 'abc123xyzEncrypted';
$encryptedCookieValue = $this->getEncryptedCookieValue('sessionIdEncrypted', $encryptedCookieOriginalValue);
$stubHeaders = [
'Set-Cookie' => [
'sessionId=abc123xyz; Path=/; HttpOnly; Secure; SameSite=Strict; Expires=Mon, 30 Sep 2025 23:59:59 GMT',
"sessionIdEncrypted={$encryptedCookieValue}; Path=/; HttpOnly; Secure; SameSite=Strict; Expires=Mon, 30 Sep 2025 23:59:59 GMT",
],
];
// Anticipate
Http::fake(function (Request $request) use ($stubStatusCode, $stubBody, $stubHeaders) {
if ($request->url() !== self::ENDPOINT) {
return null;
}
return Http::response(
body: $stubBody + ['requestHeaders' => $request->headers()],
status: $stubStatusCode,
headers: $stubHeaders,
);
});
$dummyAuthorizationHandler = $this->mock(
AuthorizationHandler::class,
fn (MockInterface $mock) => $mock->shouldReceive('authorize')
->with(Mockery::type(PendingRequest::class))
->andReturnArg(index: 0),
);
$this->mock(
AuthorizationHandlerFactory::class,
fn (MockInterface $mock) => $mock
->shouldReceive('create')
->with($authorizationCredentials)
->andReturn($dummyAuthorizationHandler),
);
$requestRelayAction = resolve(RequestRelayAction::class);
// Act
$response = $requestRelayAction->execute($requestData);
// Assert
$this->assertEquals($stubStatusCode, $response->statusCode);
$this->assertEquals($expectedStatusText, $response->statusText);
$this->assertEquals(
$stubBody,
Arr::except(
$response->body->body,
'requestHeaders',
),
);
$this->assertEquals(
$customHeaderValue,
$response->body->body['requestHeaders']['X-Custom-Header'][0] ?? -1,
);
$this->assertEquals(
CarbonImmutable::now()->timestamp,
$response->timestamp,
);
$this->assertRelayResponseCookies(
expectedCookies: [
[
'name' => 'sessionId',
'raw' => 'abc123xyz',
'decrypted' => null,
],
[
'name' => 'sessionIdEncrypted',
'raw' => $encryptedCookieValue,
'decrypted' => $encryptedCookieOriginalValue,
],
],
actualCookies: $response->cookies,
);
$this->assertEquals(
[
'Content-Type' => ['application/json'],
...$stubHeaders,
],
$response->headers,
);
$this->assertEqualsWithDelta(
5,
$response->durationMs,
delta: 50, // <- Sweet spot for fluctuation.
);
}
/*
* Helpers.
*/
private function getRandomAuthorizationCredentials(): AuthorizationCredentials
{
return Arr::random([
AuthorizationCredentials::none(),
new AuthorizationCredentials(AuthorizationTypeEnum::Basic, ['username' => 'user', 'password' => 'pass']),
new AuthorizationCredentials(AuthorizationTypeEnum::Bearer, 'foobar'),
new AuthorizationCredentials(AuthorizationTypeEnum::Impersonate, '14'),
new AuthorizationCredentials(AuthorizationTypeEnum::CurrentUser, value: null),
]);
}
private function getEncryptedCookieValue(string $cookieName, string $rawValue): string
{
return app('encrypter')
->encrypt(
value: CookieValuePrefix::create($cookieName, app('encrypter')->getKey()).$rawValue,
serialize: false,
);
}
/*
* Asserts.
*/
private function assertRelayResponseCookies(array $expectedCookies, array $actualCookies): void
{
$this->assertCount(
count($expectedCookies),
$actualCookies,
);
$this->assertContainsOnlyInstancesOf(
ResponseCookieValueObject::class,
$actualCookies,
);
foreach ($expectedCookies as $index => $expectedCookie) {
$this->assertEquals(
[
'key' => $expectedCookie['name'],
'value' => [
'raw' => $expectedCookie['raw'],
'decrypted' => $expectedCookie['decrypted'],
],
],
$actualCookies[$index]->toArray(),
);
}
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace Sunchayn\Nimbus\Tests\App\Modules\Relay\Authorization;
use PHPUnit\Framework\Attributes\CoversClass;
use Sunchayn\Nimbus\Modules\Relay\Authorization\AuthorizationCredentials;
use Sunchayn\Nimbus\Modules\Relay\Authorization\AuthorizationTypeEnum;
use Sunchayn\Nimbus\Tests\TestCase;
#[CoversClass(AuthorizationCredentials::class)]
class AuthorizationCredentialsUnitTest extends TestCase
{
public function test_it_constructs_none_state(): void
{
$credentials = AuthorizationCredentials::none();
// Assert
$this->assertEquals(AuthorizationTypeEnum::None, $credentials->type);
$this->assertNull($credentials->value);
}
}

View File

@@ -0,0 +1,144 @@
<?php
namespace Sunchayn\Nimbus\Tests\App\Modules\Relay\Authorization\Handlers;
use Closure;
use Generator;
use Illuminate\Support\Arr;
use InvalidArgumentException;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\CoversMethod;
use PHPUnit\Framework\Attributes\DataProvider;
use Sunchayn\Nimbus\Modules\Relay\Authorization\AuthorizationCredentials;
use Sunchayn\Nimbus\Modules\Relay\Authorization\AuthorizationTypeEnum;
use Sunchayn\Nimbus\Modules\Relay\Authorization\Handlers\AuthorizationHandlerFactory;
use Sunchayn\Nimbus\Modules\Relay\Authorization\Handlers\BasicAuthAuthorizationHandler;
use Sunchayn\Nimbus\Modules\Relay\Authorization\Handlers\BearerAuthorizationHandler;
use Sunchayn\Nimbus\Modules\Relay\Authorization\Handlers\CurrentUserAuthorizationHandler;
use Sunchayn\Nimbus\Modules\Relay\Authorization\Handlers\ImpersonateUserAuthorizationHandler;
use Sunchayn\Nimbus\Modules\Relay\Authorization\Handlers\NoAuthorizationHandler;
use Sunchayn\Nimbus\Tests\TestCase;
#[CoversClass(AuthorizationHandlerFactory::class)]
#[CoversMethod(BasicAuthAuthorizationHandler::class, 'fromArray')]
class AuthorizationHandlerFactoryUnitTest extends TestCase
{
#[DataProvider('createAuthorizationHandlerDataProvider')]
public function test_it_creates_handler(
AuthorizationCredentials $authorizationCredentials,
string $expectedAuthorizationHandler,
Closure $checkIsValid,
): void {
// Arrange
$factory = resolve(AuthorizationHandlerFactory::class);
// Act
$actualAuthorizationHandler = $factory->create($authorizationCredentials);
// Assert
$this->assertInstanceOf(
$expectedAuthorizationHandler,
$actualAuthorizationHandler,
);
$this->assertTrue(
$checkIsValid->call($this, $authorizationCredentials, $actualAuthorizationHandler),
);
}
public static function createAuthorizationHandlerDataProvider(): Generator
{
yield 'None handler' => [
'authorizationCredentials' => AuthorizationCredentials::none(),
'expectedAuthorizationHandler' => NoAuthorizationHandler::class,
'checkIsValid' => fn (AuthorizationCredentials $credentials, NoAuthorizationHandler $handler): bool => true,
];
yield 'Bearer handler' => [
'authorizationCredentials' => new AuthorizationCredentials(AuthorizationTypeEnum::Bearer, value: 'foobar'),
'expectedAuthorizationHandler' => BearerAuthorizationHandler::class,
'checkIsValid' => fn (AuthorizationCredentials $credentials, BearerAuthorizationHandler $handler): bool => $handler->token === 'foobar',
];
yield 'Basic handler' => [
'authorizationCredentials' => new AuthorizationCredentials(AuthorizationTypeEnum::Basic, value: ['username' => 'foo', 'password' => 'bar']),
'expectedAuthorizationHandler' => BasicAuthAuthorizationHandler::class,
'checkIsValid' => fn (AuthorizationCredentials $credentials, BasicAuthAuthorizationHandler $handler): bool => $handler->username === 'foo' && $handler->password === 'bar',
];
yield 'Impersonate handler' => [
'authorizationCredentials' => new AuthorizationCredentials(AuthorizationTypeEnum::Impersonate, value: 22),
'expectedAuthorizationHandler' => ImpersonateUserAuthorizationHandler::class,
'checkIsValid' => fn (AuthorizationCredentials $credentials, ImpersonateUserAuthorizationHandler $handler): bool => $handler->userId === 22,
];
yield 'Current User handler' => [
'authorizationCredentials' => new AuthorizationCredentials(AuthorizationTypeEnum::CurrentUser, value: 22),
'expectedAuthorizationHandler' => CurrentUserAuthorizationHandler::class,
'checkIsValid' => fn (AuthorizationCredentials $credentials, CurrentUserAuthorizationHandler $handler): bool => true,
];
}
#[DataProvider('authorizationHandlerValidationDataProvider')]
public function test_it_validates_values_upon_creation(
AuthorizationCredentials $authorizationCredentials,
string $expectedError,
): void {
// Arrange
$factory = resolve(AuthorizationHandlerFactory::class);
// Anticipate
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage($expectedError);
// Act
$factory->create($authorizationCredentials);
}
public static function authorizationHandlerValidationDataProvider(): Generator
{
yield 'Impersonate handler doesnt allow `nulls`' => [
'authorizationCredentials' => new AuthorizationCredentials(AuthorizationTypeEnum::Impersonate, value: null),
'expectedError' => 'Impersonate user ID cannot be null.',
];
yield 'Impersonate handler doesnt allow strings`' => [
'authorizationCredentials' => new AuthorizationCredentials(AuthorizationTypeEnum::Impersonate, value: 'abc'),
'expectedError' => 'Impersonate user ID must be an integer.',
];
yield 'Impersonate handler doesnt allow arrays`' => [
'authorizationCredentials' => new AuthorizationCredentials(AuthorizationTypeEnum::Impersonate, value: ['username' => 'foo', 'password' => 'bar']),
'expectedError' => 'Impersonate user ID must be an integer.',
];
yield 'Bearer handler doesnt allow `nulls`' => [
'authorizationCredentials' => new AuthorizationCredentials(AuthorizationTypeEnum::Bearer, value: null),
'expectedError' => 'Bearer token must be a string',
];
yield 'Bearer handler doesnt allow arrays' => [
'authorizationCredentials' => new AuthorizationCredentials(AuthorizationTypeEnum::Bearer, value: ['username' => 'foo', 'password' => 'bar']),
'expectedError' => 'Bearer token must be a string',
];
yield 'Basic handler doesnt allow `nulls`' => [
'authorizationCredentials' => new AuthorizationCredentials(AuthorizationTypeEnum::Basic, value: null),
'expectedError' => 'Basic auth credentials must be an array',
];
yield 'Basic handler doesnt allow strings' => [
'authorizationCredentials' => new AuthorizationCredentials(
AuthorizationTypeEnum::Basic,
value: Arr::random(['abc', 22]) // <- 22 implicitly converted to "22"
),
'expectedError' => 'Basic auth credentials must be an array',
];
}
}

View File

@@ -0,0 +1,76 @@
<?php
namespace Sunchayn\Nimbus\Tests\App\Modules\Relay\Authorization\Handlers;
use Illuminate\Http\Client\PendingRequest;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\CoversMethod;
use PHPUnit\Framework\Attributes\TestWith;
use Sunchayn\Nimbus\Modules\Relay\Authorization\Exceptions\InvalidAuthorizationValueException;
use Sunchayn\Nimbus\Modules\Relay\Authorization\Handlers\BasicAuthAuthorizationHandler;
use Sunchayn\Nimbus\Tests\TestCase;
#[CoversClass(BasicAuthAuthorizationHandler::class)]
#[CoversMethod(InvalidAuthorizationValueException::class, 'becauseBasicAuthCredentialsAreInvalid')]
class BasicAuthAuthorizationHandlerFunctionalTest extends TestCase
{
public function test_it_authorizes_requests(): void
{
// Arrange
$pendingRequest = resolve(PendingRequest::class);
$handler = new BasicAuthAuthorizationHandler(
$username = fake()->userName(),
$password = fake()->password(),
);
// Act
$pendingRequestResponse = $handler->authorize($pendingRequest);
// Assert
$this->assertEquals(
[$username, $password],
$pendingRequestResponse->getOptions()['auth'] ?? [],
);
$this->assertSame($pendingRequest, $pendingRequestResponse);
}
#[TestWith(['username' => '', 'password' => ''])]
#[TestWith(['username' => '', 'password' => ' '])]
#[TestWith(['username' => ' ', 'password' => ' '])]
public function test_it_breaks_with_invalid_credentials(string $username, string $password): void
{
// Anticipate
$this->expectException(InvalidAuthorizationValueException::class);
$this->expectExceptionMessage('Basic Auth credentials are invalid. Expects array{username: string, password: string}.');
$this->expectExceptionCode(InvalidAuthorizationValueException::BASIC_AUTH_SHAPE_IS_INVALID);
// Act
resolve(BasicAuthAuthorizationHandler::class, ['username' => $username, 'password' => $password]);
}
#[TestWith(['credentials' => ['username' => 'foobar']])]
#[TestWith(['credentials' => ['password' => 'foobar']])]
public function test_it_breaks_with_invalid_credentials_for_from_array(array $credentials): void
{
// Anticipate
$this->expectException(InvalidAuthorizationValueException::class);
$this->expectExceptionMessage('Basic Auth credentials are invalid. Expects array{username: string, password: string}.');
$this->expectExceptionCode(InvalidAuthorizationValueException::BASIC_AUTH_SHAPE_IS_INVALID);
// Act
BasicAuthAuthorizationHandler::fromArray($credentials);
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace Sunchayn\Nimbus\Tests\App\Modules\Relay\Authorization\Handlers;
use Illuminate\Http\Client\PendingRequest;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\CoversMethod;
use PHPUnit\Framework\Attributes\TestWith;
use Sunchayn\Nimbus\Modules\Relay\Authorization\Exceptions\InvalidAuthorizationValueException;
use Sunchayn\Nimbus\Modules\Relay\Authorization\Handlers\BearerAuthorizationHandler;
use Sunchayn\Nimbus\Tests\TestCase;
#[CoversClass(BearerAuthorizationHandler::class)]
#[CoversMethod(InvalidAuthorizationValueException::class, 'becauseBearerTokenValueIsNotString')]
class BearerAuthorizationHandlerFunctionalTest extends TestCase
{
public function test_it_authorizes_requests(): void
{
// Arrange
$pendingRequest = resolve(PendingRequest::class);
$token = fake()->sha256();
$handler = new BearerAuthorizationHandler("$token "); // <- Adding a space to assert trimming.
// Act
$pendingRequestResponse = $handler->authorize($pendingRequest);
// Assert
$this->assertEquals(
"Bearer $token",
$pendingRequestResponse->getOptions()['headers']['Authorization'] ?? '--not-found--',
);
$this->assertSame($pendingRequest, $pendingRequestResponse);
}
#[TestWith([''])]
#[TestWith([' '])]
public function test_it_breaks_with_invalid_token(string $invalidToken): void
{
// Anticipate
$this->expectException(InvalidAuthorizationValueException::class);
$this->expectExceptionMessage('Bearer token value is not a string.');
$this->expectExceptionCode(InvalidAuthorizationValueException::BEARER_TOKEN_IS_NOT_STRING);
// Act
resolve(BearerAuthorizationHandler::class, ['token' => $invalidToken]);
}
}

View File

@@ -0,0 +1,102 @@
<?php
namespace Sunchayn\Nimbus\Tests\App\Modules\Relay\Authorization\Handlers;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Request;
use PHPUnit\Framework\Attributes\CoversClass;
use Sunchayn\Nimbus\Modules\Relay\Authorization\Handlers\CurrentUserAuthorizationHandler;
use Sunchayn\Nimbus\Tests\App\Modules\Relay\Authorization\Handlers\Shared\HandlesRecallerCookies;
use Sunchayn\Nimbus\Tests\App\Modules\Relay\Authorization\Handlers\Stubs\DummyAuthenticatable;
use Sunchayn\Nimbus\Tests\App\Modules\Relay\Authorization\Handlers\Stubs\DummySpecialAuthenticationInjector;
use Sunchayn\Nimbus\Tests\TestCase;
#[CoversClass(CurrentUserAuthorizationHandler::class)]
class CurrentUserAuthorizationHandlerFunctionalTest extends TestCase
{
use HandlesRecallerCookies;
public function test_it_uses_special_authentication_injector_to_authorize_request(): void
{
// Arrange
config([
'nimbus.auth.guard' => $guardName = fake()->word(),
'nimbus.auth.special.injector' => DummySpecialAuthenticationInjector::class,
]);
$dummyAuthenticatable = new DummyAuthenticatable(id: $userId = fake()->randomNumber());
$this->mockAuthManagerToUseDummyModel($userId, $dummyAuthenticatable, $guardName);
$relayRequest = Request::create('ping');
$relayRequest->setUserResolver(fn () => $dummyAuthenticatable);
$dummySpecialAuthenticationInjectorMock = $this->mock(DummySpecialAuthenticationInjector::class);
$handler = resolve(CurrentUserAuthorizationHandler::class, [
'relayRequest' => $relayRequest,
]);
$pendingRequest = resolve(PendingRequest::class);
// Anticipate
$dummySpecialAuthenticationInjectorMock
->shouldReceive('attach')
->withAnyArgs()
->andReturn($pendingRequest);
// Act
$responsePendingRequest = $handler->authorize($pendingRequest);
// Assert
$this->assertSame($pendingRequest, $responsePendingRequest);
$dummySpecialAuthenticationInjectorMock
->shouldHaveReceived('attach')
->once()
->withArgs(function (PendingRequest $pendingRequestArg, Authenticatable $authenticatable) use ($pendingRequest, $dummyAuthenticatable) {
$this->assertSame(
$dummyAuthenticatable,
$authenticatable,
);
$this->assertSame($pendingRequestArg, $pendingRequest);
return true;
});
}
public function test_it_returns_unmodified_request_when_no_cookies(): void
{
// Arrange
$relayRequest = Request::create('ping');
$relayRequest->headers->set('HOST', fake()->domainName());
$handler = resolve(
CurrentUserAuthorizationHandler::class,
[
'relayRequest' => $relayRequest,
],
);
$pendingRequest = resolve(PendingRequest::class);
// Act
$pendingRequestResponse = $handler->authorize($pendingRequest);
// Assert
$cookies = $pendingRequestResponse->getOptions()['cookies'] ?? [];
$this->assertEmpty($cookies);
$this->assertSame($pendingRequest, $pendingRequestResponse);
}
}

View File

@@ -0,0 +1,127 @@
<?php
namespace Sunchayn\Nimbus\Tests\App\Modules\Relay\Authorization\Handlers;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Request;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\CoversMethod;
use PHPUnit\Framework\Attributes\TestWith;
use Sunchayn\Nimbus\Modules\Relay\Authorization\Exceptions\InvalidAuthorizationValueException;
use Sunchayn\Nimbus\Modules\Relay\Authorization\Handlers\ImpersonateUserAuthorizationHandler;
use Sunchayn\Nimbus\Tests\App\Modules\Relay\Authorization\Handlers\Shared\HandlesRecallerCookies;
use Sunchayn\Nimbus\Tests\App\Modules\Relay\Authorization\Handlers\Stubs\DummyAuthenticatable;
use Sunchayn\Nimbus\Tests\App\Modules\Relay\Authorization\Handlers\Stubs\DummySpecialAuthenticationInjector;
use Sunchayn\Nimbus\Tests\TestCase;
#[CoversClass(ImpersonateUserAuthorizationHandler::class)]
#[CoversMethod(InvalidAuthorizationValueException::class, 'becauseUserIsNotFound')]
class ImpersonateUserAuthorizationHandlerFunctionalTest extends TestCase
{
use HandlesRecallerCookies;
public function test_it_authorizes_requests(): void
{
// Arrange
config([
'nimbus.auth.guard' => $guardName = fake()->word(),
'nimbus.auth.special.injector' => DummySpecialAuthenticationInjector::class,
]);
$dummyAuthenticatable = new DummyAuthenticatable(id: $userId = fake()->randomNumber());
$this->mockAuthManagerToUseDummyModel($userId, $dummyAuthenticatable, $guardName);
$relayRequest = Request::create('ping');
$relayRequest->headers->set('HOST', $relayRequestHost = fake()->domainName());
$dummySpecialAuthenticationInjectorMock = $this->mock(DummySpecialAuthenticationInjector::class);
$handler = resolve(
ImpersonateUserAuthorizationHandler::class,
[
'userId' => $userId,
'relayRequest' => $relayRequest,
],
);
$pendingRequest = resolve(PendingRequest::class);
// Anticipate
$dummySpecialAuthenticationInjectorMock
->shouldReceive('attach')
->withAnyArgs()
->andReturn($pendingRequest);
// Act
$responsePendingRequest = $handler->authorize($pendingRequest);
// Assert
$this->assertSame($pendingRequest, $responsePendingRequest);
$dummySpecialAuthenticationInjectorMock
->shouldHaveReceived('attach')
->once()
->withArgs(function (PendingRequest $pendingRequestArg, Authenticatable $authenticatable) use ($pendingRequest, $dummyAuthenticatable) {
$this->assertSame(
$dummyAuthenticatable,
$authenticatable,
);
$this->assertSame($pendingRequestArg, $pendingRequest);
return true;
});
}
#[TestWith([0], 'ID Equals to Zero')]
#[TestWith([-1], 'Negative ID')]
public function test_it_breaks_with_invalid_user_id(int $coefficient): void
{
// Arrange
$invalidUserId = fake()->randomNumber() * $coefficient;
// Anticipate
$this->expectException(InvalidAuthorizationValueException::class);
$this->expectExceptionMessage('User ID didn\'t resolve to a user to impersonate.');
$this->expectExceptionCode(InvalidAuthorizationValueException::USER_IS_NOT_FOUND);
// Act
resolve(ImpersonateUserAuthorizationHandler::class, ['userId' => $invalidUserId]);
}
public function test_it_breaks_when_user_is_not_found(): void
{
// Arrange
$nonExistentUserId = fake()->randomNumber() + 1;
$this->mockAuthManagerToUseDummyModel($nonExistentUserId, authenticatable: null, guardName: 'web');
$pendingRequest = resolve(PendingRequest::class);
$handler = resolve(ImpersonateUserAuthorizationHandler::class, ['userId' => $nonExistentUserId]);
// Anticipate
$this->expectException(InvalidAuthorizationValueException::class);
$this->expectExceptionMessage('User ID didn\'t resolve to a user to impersonate.');
$this->expectExceptionCode(InvalidAuthorizationValueException::USER_IS_NOT_FOUND);
// Act
$handler->authorize($pendingRequest);
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace Sunchayn\Nimbus\Tests\App\Modules\Relay\Authorization\Handlers;
use Illuminate\Http\Client\PendingRequest;
use Mockery\MockInterface;
use PHPUnit\Framework\Attributes\CoversClass;
use Sunchayn\Nimbus\Modules\Relay\Authorization\Handlers\NoAuthorizationHandler;
use Sunchayn\Nimbus\Tests\TestCase;
#[CoversClass(NoAuthorizationHandler::class)]
class NoAuthAuthorizationHandlerFunctionalTest extends TestCase
{
public function test_it_does_nothing(): void
{
// Arrange
/**
* Creating a Mock in place of a Spy here so any method call breaks. We expect nothing to be called.
*
* @var PendingRequest&MockInterface $pendingRequestMock
*/
$pendingRequestMock = $this->mock(PendingRequest::class);
$handler = resolve(NoAuthorizationHandler::class);
// Act
$pendingRequestResponse = $handler->authorize($pendingRequestMock);
// Assert
$this->assertSame($pendingRequestMock, $pendingRequestResponse);
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace Sunchayn\Nimbus\Tests\App\Modules\Relay\Authorization\Handlers\Shared;
use GuzzleHttp\Cookie\SetCookie;
use Illuminate\Auth\AuthManager;
use Illuminate\Contracts\Auth\Guard;
use Illuminate\Contracts\Auth\UserProvider;
use Illuminate\Cookie\CookieValuePrefix;
use Mockery\MockInterface;
use Sunchayn\Nimbus\Tests\App\Modules\Relay\Authorization\Handlers\Stubs\DummyAuthenticatable;
trait HandlesRecallerCookies
{
private const COOKIE_RECALLER_NAME = 'dummy_recaller';
/*
* Mocks.
*/
private function mockAuthManagerToUseDummyModel(int $userId, ?DummyAuthenticatable $authenticatable, string $guardName): void
{
$userProvider = $this->mock(UserProvider::class, function (MockInterface $mock) use ($userId, $authenticatable) {
$mock
->shouldReceive('retrieveById')
->with($userId)
->andReturn($authenticatable);
});
$this->mockAuthManager($userProvider, $guardName);
}
private function mockAuthManager(MockInterface $userProvider, string $guard): void
{
$guardMock = $this->mock(Guard::class);
$guardMock->shouldReceive('getProvider')->andReturn($userProvider);
$guardMock->shouldReceive('getRecallerName')->andReturn(self::COOKIE_RECALLER_NAME);
$authManagerMock = $this->mock(AuthManager::class);
$authManagerMock->shouldReceive('guard')->with($guard)->andReturn($guardMock);
app()->instance('auth', $authManagerMock);
}
/*
* Asserts.
*/
private function assertCookieValue(SetCookie $cookie, string $expectedCookieValue): void
{
$encrypter = resolve('encrypter');
$expectedCookiePrefix = CookieValuePrefix::create(self::COOKIE_RECALLER_NAME, $encrypter->getKey());
$actualValueWithPrefix = $encrypter->decrypt($cookie->getValue(), unserialize: false);
$this->assertStringStartsWith(
$expectedCookiePrefix,
$actualValueWithPrefix,
'The (decrypted) cookie value doesn\'t have the cookie prefix.',
);
$actualValue = str_replace($expectedCookiePrefix, '', $actualValueWithPrefix);
$this->assertEquals(
$expectedCookieValue,
$actualValue,
);
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace Sunchayn\Nimbus\Tests\App\Modules\Relay\Authorization\Handlers\Stubs;
use Illuminate\Contracts\Auth\Authenticatable;
class DummyAuthenticatable implements Authenticatable
{
private readonly int $id;
private string $rememberToken;
public function __construct(
?int $id = null,
?string $rememberToken = null,
) {
$this->id = $id ?? fake()->randomNumber();
$this->rememberToken = $rememberToken ?? fake()->sha256();
}
public function getAuthIdentifierName()
{
return 'id';
}
public function getAuthIdentifier()
{
return $this->id;
}
public function getAuthPasswordName()
{
return 'password';
}
public function getAuthPassword()
{
return fake()->password();
}
public function getRememberToken()
{
return $this->rememberToken;
}
public function setRememberToken($value)
{
$this->rememberToken = $value;
}
public function getRememberTokenName()
{
return 'remember_token';
}
public function getRecallerCookieValue(): string
{
return $this->getAuthIdentifier().'|'.$this->getRememberToken().'|'.$this->getAuthPasswordName();
}
}

View File

@@ -0,0 +1,12 @@
<?php
namespace Sunchayn\Nimbus\Tests\App\Modules\Relay\Authorization\Handlers\Stubs;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Http\Client\PendingRequest;
use Sunchayn\Nimbus\Modules\Relay\Authorization\Contracts\SpecialAuthenticationInjectorContract;
class DummySpecialAuthenticationInjector implements SpecialAuthenticationInjectorContract
{
public function attach(PendingRequest $pendingRequest, Authenticatable $authenticatable): PendingRequest {}
}

View File

@@ -0,0 +1,416 @@
<?php
namespace Sunchayn\Nimbus\Tests\App\Modules\Relay\Authorization\Injectors;
use Illuminate\Auth\SessionGuard;
use Illuminate\Config\Repository as ConfigRepository;
use Illuminate\Container\Container;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Contracts\Auth\UserProvider;
use Illuminate\Encryption\Encrypter;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Request;
use Mockery;
use PHPUnit\Framework\Attributes\CoversClass;
use Sunchayn\Nimbus\Modules\Relay\Authorization\Injectors\RememberMeCookieInjector;
use Sunchayn\Nimbus\Tests\TestCase;
#[CoversClass(RememberMeCookieInjector::class)]
class RememberMeCookieInjectorUnitTest extends TestCase
{
private Container $containerMock;
private ConfigRepository $configMock;
private SessionGuard $authGuardMock;
private UserProvider $userProviderMock;
private Encrypter $encrypterMock;
protected function setUp(): void
{
parent::setUp();
$this->containerMock = Mockery::mock(Container::class);
$this->configMock = Mockery::mock(ConfigRepository::class);
$this->authGuardMock = Mockery::mock(SessionGuard::class);
$this->userProviderMock = Mockery::mock(UserProvider::class);
$this->encrypterMock = Mockery::mock(Encrypter::class);
}
public function test_it_generates_and_attaches_remember_me_cookie_for_new_user(): void
{
// Arrange
$authenticatable = $this->createAuthenticatable(
id: 123,
rememberToken: 'existing_token',
passwordField: 'password'
);
$relayRequest = Request::create('ping', server: ['HTTP_HOST' => 'example.com']);
$relayRequest->setUserResolver(fn () => null); // <- no authenticated user in relay request
$this->authGuardMock
->shouldReceive('getRecallerName')
->andReturn('remember_web');
$this->encrypterMock
->shouldReceive('getKey')
->andReturn('base64:test-key');
$this->encrypterMock
->shouldReceive('encrypt')
->withArgs(function (mixed $value, bool $serialize) {
$this->assertStringEndsWith(
'|123|existing_token|password',
$value,
);
$this->assertFalse($serialize);
return true;
})
->once()
->andReturn('encrypted_recaller_token');
$pendingRequestMock = Mockery::mock(PendingRequest::class);
$injector = $this->instantiateInjector($relayRequest);
// Anticipate
$pendingRequestMock
->shouldReceive('withCookies')
->once()
->withArgs(function (array $withCookiesArg, string $domain) {
$this->assertEquals(
['remember_web' => 'encrypted_recaller_token'],
$withCookiesArg,
);
$this->assertEquals(
'example.com',
$domain,
);
return true;
})
->andReturnSelf();
// Act
$result = $injector->attach($pendingRequestMock, $authenticatable);
// Assert
$this->assertSame($pendingRequestMock, $result);
}
public function test_it_generates_new_remember_token_when_user_has_none(): void
{
// Arrange
$authenticatable = $this->createAuthenticatable(
id: 456,
rememberToken: null, // <- no existing token
passwordField: 'password'
);
$relayRequest = Request::create('ping', server: ['HTTP_HOST' => 'example.com']);
$relayRequest->setUserResolver(fn () => null);
$this->authGuardMock
->shouldReceive('getRecallerName')
->andReturn('remember_web');
$this->encrypterMock
->shouldReceive('getKey')
->andReturn('base64:test-key');
$this->encrypterMock
->shouldReceive('encrypt')
->andReturn('encrypted_recaller_token');
$pendingRequestMock = Mockery::mock(PendingRequest::class);
$pendingRequestMock
->shouldReceive('withCookies')
->andReturnSelf();
$injector = $this->instantiateInjector($relayRequest);
// Anticipate
$this->userProviderMock->expects('updateRememberToken');
// Act
$injector->attach($pendingRequestMock, $authenticatable);
// Assert
$this
->userProviderMock
->shouldHaveReceived('updateRememberToken')
->once()
->withArgs(function (Authenticatable $user, string $token) {
$this->assertSame(456, $user->getAuthIdentifier());
$this->assertMatchesRegularExpression('/^[a-f0-9]{64}$/', $token);
return true;
});
}
public function test_it_forwards_existing_remember_me_cookie_for_same_user(): void
{
// Arrange
$currentUser = $this->createAuthenticatable(
id: 789,
rememberToken: 'current_token',
passwordField: 'password'
);
$targetUser = $this->createAuthenticatable(
id: 789, // <- same ID as current user
rememberToken: 'target_token',
passwordField: 'password'
);
$relayRequest = Request::create('ping', server: ['HTTP_HOST' => 'example.com']);
$relayRequest->setUserResolver(fn () => $currentUser);
$relayRequest->cookies->set('remember_web', $encrytpedCookieValue = sha1('::value::'));
$this->authGuardMock
->shouldReceive('getRecallerName')
->andReturn('remember_web');
$this->encrypterMock
->shouldReceive('getKey')
->andReturn('base64:test-key');
$pendingRequestMock = Mockery::mock(PendingRequest::class);
$injector = $this->instantiateInjector($relayRequest);
// Anticipate
$this->encrypterMock->shouldNotReceive('encrypt');
$pendingRequestMock
->expects('withCookies')
->andReturnSelf();
// Act
$result = $injector->attach($pendingRequestMock, $targetUser);
// Assert
$this->assertSame($pendingRequestMock, $result);
$pendingRequestMock
->shouldHaveReceived('withCookies')
->once()
->withArgs(function (array $cookies, string $domain) use ($encrytpedCookieValue) {
$this->assertEquals(
['remember_web' => $encrytpedCookieValue],
$cookies,
);
$this->assertEquals('example.com', $domain);
return true;
});
}
public function test_it_does_not_forward_cookie_when_users_differ(): void
{
// Arrange
$currentUser = $this->createAuthenticatable(
id: 100,
rememberToken: 'current_token',
passwordField: 'password'
);
$targetUser = $this->createAuthenticatable(
id: 200, // <- different ID
rememberToken: 'target_token',
passwordField: 'password2'
);
$relayRequest = Request::create('ping', server: ['HTTP_HOST' => 'example.com']);
$relayRequest->setUserResolver(fn () => $currentUser);
$relayRequest->cookies->set('remember_web', 'existing_cookie_value');
$this->authGuardMock
->shouldReceive('getRecallerName')
->andReturn('remember_web');
$this->encrypterMock
->shouldReceive('getKey')
->andReturn('base64:test-key');
$pendingRequestMock = Mockery::mock(PendingRequest::class);
$injector = $this->instantiateInjector($relayRequest);
// Anticipate
$this->encrypterMock
->shouldReceive('encrypt')
->once()
->withArgs(function (mixed $value, bool $serialize) {
$this->assertStringEndsWith('|200|target_token|password2', $value);
$this->assertFalse($serialize);
return true;
})
->andReturn('encrypted_new_cookie');
$pendingRequestMock
->shouldReceive('withCookies')
->once()
->withArgs(function (array $cookies, string $domain) {
$this->assertEquals(
['remember_web' => 'encrypted_new_cookie'],
$cookies,
);
$this->assertEquals('example.com', $domain);
return true;
})
->andReturnSelf();
// Act
$result = $injector->attach($pendingRequestMock, $targetUser);
// Assert
$this->assertSame($pendingRequestMock, $result);
}
public function test_it_does_not_forward_when_no_cookie_exists(): void
{
// Arrange
$currentUser = $this->createAuthenticatable(
id: 100,
rememberToken: 'current_token',
passwordField: 'password'
);
$targetUser = $this->createAuthenticatable(
id: 100,
rememberToken: 'target_token',
passwordField: 'password'
);
$relayRequest = Request::create('ping', server: ['HTTP_HOST' => 'example.com']);
$relayRequest->setUserResolver(fn () => $currentUser);
// No cookie set
$this->authGuardMock
->shouldReceive('getRecallerName')
->andReturn('remember_web');
$this->encrypterMock
->shouldReceive('getKey')
->andReturn('base64:test-key');
$pendingRequestMock = Mockery::mock(PendingRequest::class);
$injector = $this->instantiateInjector($relayRequest);
// Anticipate
$this->encrypterMock
->shouldReceive('encrypt')
->once()
->withArgs(function (mixed $value, bool $serialize) {
$this->assertStringEndsWith('|100|target_token|password', $value);
$this->assertFalse($serialize);
return true;
})
->andReturn('encrypted_new_cookie');
$pendingRequestMock
->shouldReceive('withCookies')
->once()
->andReturnSelf();
// Act
$result = $injector->attach($pendingRequestMock, $targetUser);
// Assert
$this->assertSame($pendingRequestMock, $result);
}
/*
* Helpers.
*/
private function instantiateInjector(Request $relayRequest): RememberMeCookieInjector
{
$this->configMock
->shouldReceive('get')
->with('nimbus.auth.guard')
->andReturn('web');
$authManagerMock = Mockery::mock(\Illuminate\Auth\AuthManager::class);
$authManagerMock
->shouldReceive('guard')
->with('web')
->andReturn($this->authGuardMock);
$this->authGuardMock
->shouldReceive('getProvider')
->andReturn($this->userProviderMock);
$this->containerMock
->shouldReceive('get')
->with('encrypter')
->andReturn($this->encrypterMock);
$this->containerMock
->shouldReceive('get')
->with('auth')
->andReturn($authManagerMock);
return new RememberMeCookieInjector(
relayRequest: $relayRequest,
container: $this->containerMock,
configRepository: $this->configMock,
);
}
private function createAuthenticatable(
int $id,
?string $rememberToken,
string $passwordField
): Authenticatable {
$authenticatable = Mockery::mock(Authenticatable::class);
$authenticatable
->shouldReceive('getAuthIdentifier')
->andReturn($id);
$authenticatable
->shouldReceive('getRememberToken')
->andReturn($rememberToken);
$authenticatable
->shouldReceive('getAuthPasswordName')
->andReturn($passwordField);
return $authenticatable;
}
}

View File

@@ -0,0 +1,108 @@
<?php
namespace Sunchayn\Nimbus\Tests\App\Modules\Relay\Authorization\Injectors;
use Illuminate\Config\Repository as ConfigRepository;
use Illuminate\Container\Container;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Contracts\Auth\Guard;
use Illuminate\Http\Client\PendingRequest;
use Mockery;
use PHPUnit\Framework\Attributes\CoversClass;
use Sunchayn\Nimbus\Modules\Config\Exceptions\MisconfiguredValueException;
use Sunchayn\Nimbus\Modules\Relay\Authorization\Injectors\TymonJwtTokenInjector;
use Sunchayn\Nimbus\Tests\TestCase;
#[CoversClass(TymonJwtTokenInjector::class)]
class TymonJwtTokenInjectorUnitTest extends TestCase
{
private ConfigRepository $configMock;
private Container $containerMock;
protected function setUp(): void
{
parent::setUp();
$this->configMock = Mockery::mock(ConfigRepository::class);
$this->containerMock = Mockery::mock(Container::class);
}
public function test_it_throws_exception_when_tymon_jwt_auth_not_installed(): void
{
// Arrange
if (class_exists(\Tymon\JWTAuth\JWTGuard::class)) {
$this->markTestSkipped('Tymon JWT Auth is installed, cannot test missing dependency scenario');
}
// Anticipate
$this->expectException(MisconfiguredValueException::class);
$this->expectExceptionMessage('The config value for `nimbus.auth.special.injector` is an injector that requires the following dependency <tymon/jwt-auth>');
// Act
new TymonJwtTokenInjector(
configRepository: $this->configMock,
container: $this->containerMock,
);
}
public function test_it_generates_jwt_token_and_attaches_to_request(): void
{
// Arrange
$authenticatable = Mockery::mock(Authenticatable::class);
$authenticatable
->shouldReceive('getAuthIdentifier')
->andReturn(123);
$guardMock = Mockery::mock(Guard::class);
$pendingRequestMock = Mockery::mock(PendingRequest::class);
$injector = $this->instantiateInjector($guardMock);
// Anticipate
$guardMock
->shouldReceive('login')
->with($authenticatable)
->once()
->andReturn('eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.test.token');
$pendingRequestMock
->shouldReceive('withToken')
->once()
->withArgs(function (string $token) {
$this->assertEquals('eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.test.token', $token);
return true;
})
->andReturnSelf();
// Act
$result = $injector->attach($pendingRequestMock, $authenticatable);
// Assert
$this->assertSame($pendingRequestMock, $result);
}
/*
* Helpers.
*/
private function instantiateInjector(
Guard $guardMock
): Mockery\MockInterface&TymonJwtTokenInjector {
$mock = $this->mock(TymonJwtTokenInjector::class)->shouldAllowMockingProtectedMethods()->makePartial();
$mock->shouldReceive('getGuard')->andReturn($guardMock);
return $mock;
}
}

View File

@@ -0,0 +1,81 @@
<?php
namespace Sunchayn\Nimbus\Tests\App\Modules\Relay\DataTransferObjects;
use Mockery;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use Sunchayn\Nimbus\Http\Api\Relay\NimbusRelayRequest;
use Sunchayn\Nimbus\Modules\Relay\Authorization\AuthorizationTypeEnum;
use Sunchayn\Nimbus\Modules\Relay\DataTransferObjects\RequestRelayData;
use Symfony\Component\HttpFoundation\InputBag;
#[CoversClass(RequestRelayData::class)]
class RequestRelayDataUnitTest extends TestCase
{
public function test_it_creates_instance_from_api_request(): void
{
// Arrange
$mockRequest = Mockery::mock(NimbusRelayRequest::class);
$mockRequest->shouldReceive('userAgent')->andReturn('::dummy_user_agent::');
$mockRequest->shouldReceive('host')->andReturn('::dummy_host::');
$mockCookies = new InputBag;
$mockRequest->cookies = $mockCookies;
$stubAuthorizationType = AuthorizationTypeEnum::Bearer;
// Anticipate
$mockRequest
->shouldReceive('validated')
->andReturn(
[
'method' => $method = 'POST',
'endpoint' => $endpoint = '/api/test',
'authorization' => [
'type' => $stubAuthorizationType->value,
'value' => $authorizationValue = 'foobar',
],
'headers' => [
['key' => 'Content-Type', 'value' => 'application/json'],
['key' => 'X-Custom-Header', 'value' => '::value::'],
],
'body' => $body = ['test' => 'data'],
],
);
$mockRequest->shouldReceive('getBody')->andReturn($body);
// Act
$result = RequestRelayData::fromRelayApiRequest($mockRequest);
// Assert
$this->assertEquals(strtolower($method), $result->method);
$this->assertEquals($endpoint, $result->endpoint);
$this->assertEquals($stubAuthorizationType, $result->authorization->type);
$this->assertEquals($authorizationValue, $result->authorization->value);
$this->assertEquals(
[
'accept' => 'application/json',
'content-type' => 'application/json',
'x-custom-header' => '::value::',
'user-agent' => '::dummy_user_agent::',
],
$result->headers);
$this->assertEquals($body, $result->body);
$this->assertSame($mockCookies, $result->cookies);
}
}

View File

@@ -0,0 +1,106 @@
<?php
namespace Sunchayn\Nimbus\Tests\App\Modules\Relay\ValueObjects;
use Illuminate\Http\Client\Response;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use Sunchayn\Nimbus\Modules\Relay\ValueObjects\PrintableResponseBody;
#[CoversClass(PrintableResponseBody::class)]
class PrintableResponseBodyUnitTest extends TestCase
{
public function test_it_constructs_with_array_body(): void
{
// Arrange
$body = ['foo' => 'bar', 'bar' => 'http://example.com/foo'];
// Act
$printable = new PrintableResponseBody($body);
// Assert
$this->assertSame($body, $printable->body);
$this->assertSame(<<<'EXPECTED'
{
"foo": "bar",
"bar": "http://example.com/foo"
}
EXPECTED,
$printable->toPrettyJSON(),
);
}
public function test_it_constructs_with_string_body(): void
{
// Arrange
$body = 'plain string';
// Act
$printable = new PrintableResponseBody($body);
// Assert
$this->assertSame($body, $printable->body);
$this->assertSame($body, $printable->toPrettyJSON());
}
public function test_it_creates_from_response_with_json_body(): void
{
// Arrange
$jsonData = ['hello' => 'world'];
$response = $this->mockResponse(jsonMethodReturn: $jsonData);
// Act
$printable = PrintableResponseBody::fromResponse($response);
// Assert
$this->assertSame($jsonData, $printable->body);
$this->assertSame(json_encode($jsonData, JSON_PRETTY_PRINT), $printable->toPrettyJSON());
}
public function test_it_from_response_with_string_body(): void
{
// Arrange
$stringData = 'raw string body';
// Act
$response = $this->mockResponse(bodyMethodReturn: $stringData);
// Assert
$printable = PrintableResponseBody::fromResponse($response);
$this->assertSame($stringData, $printable->body);
$this->assertSame($stringData, $printable->toPrettyJSON());
}
/*
* Mocks.
*/
private function mockResponse(?array $jsonMethodReturn = null, ?string $bodyMethodReturn = null): Response
{
$mock = $this->getMockBuilder(Response::class)
->disableOriginalConstructor()
->getMock();
$mock->method('json')->willReturn($jsonMethodReturn);
$mock->method('body')->willReturn($bodyMethodReturn ?? '');
return $mock;
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace Sunchayn\Nimbus\Tests\App\Modules\Relay\ValueObjects;
use Mockery;
use PHPUnit\Framework\Attributes\CoversClass;
use Sunchayn\Nimbus\Modules\Relay\ValueObjects\ResponseCookieValueObject;
#[CoversClass(ResponseCookieValueObject::class)]
class ResponseCookieValueObjectFunctionalTest extends \Sunchayn\Nimbus\Tests\TestCase
{
public function test_it_computes_decrypted_value(): void
{
// Arrange
$prefix = fake()->word();
$unencryptedValue = fake()->uuid();
$rawValue = encrypt($prefix.$unencryptedValue, serialize: false);
// Act
$actual = new ResponseCookieValueObject('foobar', rawValue: $rawValue, prefix: $prefix);
// Assert
$this->assertEquals(
$unencryptedValue,
invade($actual)->decryptedValue,
);
}
public function test_it_coverts_to_array(): void
{
// Arrange
$prefix = fake()->word();
$unencryptedValue = fake()->uuid();
$rawValue = base64_encode($unencryptedValue); // <- Dummy value.
// Mocked so that the __constructor is not called. We want to pretend it is already constructed.
$invadedInstance = invade(Mockery::mock(ResponseCookieValueObject::class)->makePartial());
$invadedInstance->key = $key = fake()->word();
$invadedInstance->rawValue = $rawValue;
$invadedInstance->decryptedValue = $unencryptedValue;
$invadedInstance->prefix = $prefix;
// Act
$actual = $invadedInstance->toArray();
// Assert
$this->assertEquals(
[
'key' => $key,
'value' => [
'raw' => $rawValue,
'decrypted' => $unencryptedValue,
],
],
$actual,
);
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace Sunchayn\Nimbus\Tests\App\Modules\Routes\Actions;
use PHPUnit\Framework\Attributes\CoversClass;
use Sunchayn\Nimbus\Modules\Routes\Actions\BuildCurrentUserAction;
use Sunchayn\Nimbus\Tests\App\Modules\Relay\Authorization\Handlers\Stubs\DummyAuthenticatable;
use Sunchayn\Nimbus\Tests\TestCase;
#[CoversClass(BuildCurrentUserAction::class)]
class BuildCurrentUserActionFunctionalTest extends TestCase
{
public function test_it_builds_current_user(): void
{
// Arrange
$user = new DummyAuthenticatable($id = fake()->randomNumber());
auth()->setUser($user);
$action = resolve(BuildCurrentUserAction::class);
// Act
$metadata = $action->execute();
// Assert
$this->assertEquals(
[
'id' => $id,
],
$metadata
);
}
public function test_it_builds_nothing_for_guests(): void
{
// Arrange
$action = resolve(BuildCurrentUserAction::class);
// Act
$metadata = $action->execute();
// Assert
$this->assertEmpty($metadata);
}
}

View File

@@ -0,0 +1,95 @@
<?php
namespace Sunchayn\Nimbus\Tests\App\Modules\Routes\Actions;
use Illuminate\Config\Repository;
use Mockery\MockInterface;
use PHPUnit\Framework\Attributes\CoversClass;
use Sunchayn\Nimbus\Modules\Config\GlobalHeaderGeneratorTypeEnum;
use Sunchayn\Nimbus\Modules\Routes\Actions\BuildGlobalHeadersAction;
use Sunchayn\Nimbus\Tests\TestCase;
#[CoversClass(BuildGlobalHeadersAction::class)]
class BuildGlobalHeadersActionFunctionalTest extends TestCase
{
public function test_it_builds_global_headers(): void
{
// Arrange
$globalHeadersConfig = [
'x-request-id' => GlobalHeaderGeneratorTypeEnum::Uuid,
'x-author-email' => GlobalHeaderGeneratorTypeEnum::Email,
'x-author-id' => GlobalHeaderGeneratorTypeEnum::String,
'X-Custom-Header' => '::value::',
];
$this->mock(
Repository::class,
function (MockInterface $mock) use ($globalHeadersConfig) {
$mock
->shouldReceive('get')
->with('nimbus.headers')
->andReturn($globalHeadersConfig);
},
);
$action = resolve(BuildGlobalHeadersAction::class);
// Act
$headers = $action->execute();
// Assert
$this->assertEquals(
[
[
'header' => 'x-request-id',
'type' => 'generator',
'value' => 'UUID',
],
[
'header' => 'x-author-email',
'type' => 'generator',
'value' => 'Email',
],
[
'header' => 'x-author-id',
'type' => 'generator',
'value' => 'String',
],
[
'header' => 'X-Custom-Header',
'type' => 'raw',
'value' => '::value::',
],
],
$headers
);
}
public function test_it_works_without_headers(): void
{
// Arrange
$this->mock(
Repository::class,
function (MockInterface $mock) {
$mock
->shouldReceive('get')
->with('nimbus.headers')
->andReturn([]);
},
);
$action = resolve(BuildGlobalHeadersAction::class);
// Act
$headers = $action->execute();
// Assert
$this->assertEmpty($headers);
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace Sunchayn\Nimbus\Tests\App\Modules\Routes\Actions;
use PHPUnit\Framework\Attributes\CoversClass;
use Sunchayn\Nimbus\Modules\Routes\Actions\DisableThirdPartyUiAction;
use Sunchayn\Nimbus\Tests\TestCase;
#[CoversClass(DisableThirdPartyUiAction::class)]
class DisableThirdPartyUiActionFunctionalTest extends TestCase
{
public function test_it_disables_debug_bar_w_hen_it_exists(): void
{
// TODO [Test] Figure out a way to test this.
$this->addToAssertionCount(1);
}
}

View File

@@ -0,0 +1,109 @@
<?php
namespace Sunchayn\Nimbus\Tests\App\Modules\Routes\Actions;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\TestWith;
use Sunchayn\Nimbus\Modules\Routes\Actions\IgnoreRouteErrorAction;
use Sunchayn\Nimbus\Modules\Routes\Services\IgnoredRoutesService;
use Sunchayn\Nimbus\Tests\TestCase;
#[CoversClass(IgnoreRouteErrorAction::class)]
class IgnoreRouteErrorActionFunctionalTest extends TestCase
{
public function test_it_ignores_routes_when_applicable(): void
{
// Arrange
$ignoredRoutesServiceSpy = $this->spy(IgnoredRoutesService::class);
$ignoredRouteErrorAction = resolve(IgnoreRouteErrorAction::class);
$uri = 'api/users';
$methods = ['GET', 'POST'];
$ignoreData = $uri.'|'.json_encode($methods);
// Act
$ignoredRouteErrorAction->execute($ignoreData);
// Assert
$ignoredRoutesServiceSpy
->shouldHaveReceived(
'add',
function (string $uriArg, array $methodsArg) use ($uri, $methods) {
$this->assertEquals(
$uri,
$uriArg,
);
$this->assertEquals(
$methods,
$methodsArg,
);
return true;
},
);
}
public function test_it_gracefully_workaround_invalid_methods_structure(): void
{
// Arrange
$ignoredRoutesServiceSpy = $this->spy(IgnoredRoutesService::class);
$ignoredRouteErrorAction = resolve(IgnoreRouteErrorAction::class);
$uri = 'api/users';
$methods = ['GET', 'POST'];
$ignoreData = $uri.'|'.implode(',', $methods);
// Act
$ignoredRouteErrorAction->execute($ignoreData);
// Assert
$ignoredRoutesServiceSpy
->shouldHaveReceived(
'add',
function (string $uriArg, array $methodsArg) use ($uri) {
$this->assertEquals(
$uri,
$uriArg,
);
$this->assertEquals(
[], // <- Didn't use the methods and used empty array instead.
$methodsArg,
);
return true;
},
);
}
#[TestWith(['foobar'], 'Missing Parts')]
#[TestWith(['foobar|["get","post"]|foobaz'], 'Extra Parts')]
#[TestWith(['|["get","post"]'], 'Empty URI')]
public function test_it_does_nothing_with_broken_input(string $ignoreData): void
{
// Arrange
$ignoredRoutesServiceSpy = $this->spy(IgnoredRoutesService::class);
$ignoredRouteErrorAction = resolve(IgnoreRouteErrorAction::class);
// Act
$ignoredRouteErrorAction->execute($ignoreData);
// Assert
$ignoredRoutesServiceSpy->shouldNotHaveReceived('add');
}
}

View File

@@ -0,0 +1,230 @@
<?php
namespace Sunchayn\Nimbus\Tests\App\Modules\Routes\Collections;
use Error;
use Generator;
use Illuminate\Support\Arr;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Sunchayn\Nimbus\Modules\Routes\Collections\ExtractedRoutesCollection;
use Sunchayn\Nimbus\Modules\Routes\DataTransferObjects\ExtractedRoute;
use Sunchayn\Nimbus\Modules\Routes\ValueObjects\Endpoint;
use Sunchayn\Nimbus\Modules\Routes\ValueObjects\RulesExtractionError;
use Sunchayn\Nimbus\Modules\Schemas\ValueObjects\Schema;
use Sunchayn\Nimbus\Modules\Schemas\ValueObjects\SchemaProperty;
#[CoversClass(ExtractedRoutesCollection::class)]
class ExtractedRoutesCollectionUnitTest extends TestCase
{
#[DataProvider('covertToFrontendArrayDataProvider')]
public function test_it_converts_to_frontend_array(
array $items,
array $expected,
): void {
// Arrange
$collection = new ExtractedRoutesCollection($items);
// Act
$output = $collection->toFrontendArray();
// Assert
$output = $this->replaceTraceWithPlaceholder($output);
$this->assertEquals(
$expected,
$output,
);
}
public static function covertToFrontendArrayDataProvider(): Generator
{
yield 'Empty array' => [
'items' => [],
'expected' => [],
];
yield 'Filled array' => [
'items' => [
new ExtractedRoute(
uri: new Endpoint(
version: 'v1',
resource: 'users',
value: '/api/users',
),
methods: ['GET'],
schema: Schema::empty(),
),
new ExtractedRoute(
uri: new Endpoint(
version: 'v1',
resource: 'users',
value: '/api/users',
),
methods: ['POST'],
schema: Schema::empty(),
),
new ExtractedRoute(
uri: new Endpoint(
version: 'v1',
resource: 'posts',
value: '/api/posts',
),
methods: ['POST'],
schema: new Schema(
properties: [
new SchemaProperty(
name: 'type',
),
],
),
),
],
'expected' => [
'v1' => [
'users' => [
[
'uri' => '/api/users',
'shortUri' => 'users',
'methods' => ['GET'],
'schema' => [
'$schema' => 'https://json-schema.org/draft/2020-12/schema',
'type' => 'object',
'properties' => [],
'required' => [],
'additionalProperties' => false,
],
'extractionError' => null,
],
[
'uri' => '/api/users',
'shortUri' => 'users',
'methods' => ['POST'],
'schema' => [
'$schema' => 'https://json-schema.org/draft/2020-12/schema',
'type' => 'object',
'properties' => [],
'required' => [],
'additionalProperties' => false,
],
'extractionError' => null,
],
],
'posts' => [
[
'uri' => '/api/posts',
'shortUri' => 'posts',
'methods' => ['POST'],
'schema' => [
'$schema' => 'https://json-schema.org/draft/2020-12/schema',
'type' => 'object',
'properties' => [
'type' => [
'type' => 'string',
'x-name' => 'type',
'x-required' => false,
],
],
'required' => [],
'additionalProperties' => false,
],
'extractionError' => null,
],
],
],
],
];
yield 'Filled array with errors' => [
'items' => [
new ExtractedRoute(
uri: new Endpoint(
version: 'v1',
resource: 'users',
value: '/api/users',
),
methods: ['GET'],
schema: Schema::empty(),
),
new ExtractedRoute(
uri: new Endpoint(
version: 'v1',
resource: 'users',
value: '/api/users',
),
methods: ['POST'],
schema: new Schema(
properties: [],
extractionError: new RulesExtractionError(
throwable: new Error,
)
),
),
],
'expected' => [
'v1' => [
'users' => [
[
'uri' => '/api/users',
'shortUri' => 'users',
'methods' => ['GET'],
'schema' => [
'$schema' => 'https://json-schema.org/draft/2020-12/schema',
'type' => 'object',
'properties' => [],
'required' => [],
'additionalProperties' => false,
],
'extractionError' => null,
],
[
'uri' => '/api/users',
'shortUri' => 'users',
'methods' => ['POST'],
'schema' => [
'$schema' => 'https://json-schema.org/draft/2020-12/schema',
'type' => 'object',
'properties' => [],
'required' => [],
'additionalProperties' => false,
],
'extractionError' => '<b>[no error message]</b><br />
<small>'.__FILE__.'::163</small>
<p class="text-xs">[trace]</p>',
],
],
],
],
];
}
/*
* Helpers.
*/
private function replaceTraceWithPlaceholder(array $output): array
{
return Arr::map(
$output,
fn (array $routesInVersion) => Arr::map(
$routesInVersion,
fn (array $resourceRoutes) => Arr::map(
$resourceRoutes,
function (array $route) {
if ($route['extractionError'] === null) {
return $route;
}
$route['extractionError'] = preg_replace('#(<p\b[^>]*>).*?(</p>)#si', '$1[trace]$2', $route['extractionError']);
return $route;
},
)
)
);
}
}

View File

@@ -0,0 +1,7 @@
<?php
namespace Sunchayn\Nimbus\Tests\App\Modules\Routes\Collections\Stubs;
use Error;
class DummyException extends Error {}

View File

@@ -0,0 +1,71 @@
<?php
namespace Sunchayn\Nimbus\Tests\App\Modules\Routes\Extractors\Ast;
use Generator;
use PhpParser\NodeTraverser;
use PhpParser\ParserFactory;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Sunchayn\Nimbus\Modules\Routes\Extractor\Ast\ConvertNodeToConcreteValue;
use Sunchayn\Nimbus\Modules\Routes\Extractor\Ast\RulesMethodVisitor;
use Sunchayn\Nimbus\Modules\Schemas\Collections\Ruleset;
#[CoversClass(RulesMethodVisitor::class)]
#[CoversClass(ConvertNodeToConcreteValue::class)]
class RulesMethodVisitorUnitTest extends TestCase
{
#[DataProvider('scenariosDataProvider')]
public function test_it_works(
string $phpCode,
array $expectedRules,
): void {
// Arrange
// Parse the stub into AST
$parser = (new ParserFactory)->createForNewestSupportedVersion();
$ast = $parser->parse($phpCode);
$visitor = new RulesMethodVisitor;
$traverser = new NodeTraverser;
$traverser->addVisitor($visitor);
// Act
$traverser->traverse($ast);
// Assert
$this->assertEquals(Ruleset::fromLaravelRules($expectedRules), $visitor->getRules());
}
public static function scenariosDataProvider(): Generator
{
yield 'simple call' => [
'phpCode' => file_get_contents(__DIR__.'/Stubs/FormRequestStub.php'),
'expectedRules' => [
'name' => 'required|string',
'email' => 'required|email',
],
];
yield 'with variables call' => [
'phpCode' => file_get_contents(__DIR__.'/Stubs/FormRequestWithVariablesStub.php'),
'expectedRules' => [
'name' => 'required|string',
'email' => 'required|string|email',
],
];
yield 'with input conditional call' => [
'phpCode' => file_get_contents(__DIR__.'/Stubs/FormRequestWithInputConditionalStub.php'),
'expectedRules' => [
'name' => null, // <- Unable cannot figure it out given it is conditional.
'email' => 'required|string|email',
],
];
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace Sunchayn\Nimbus\Tests\App\Modules\Routes\Extractors\Ast\Stubs;
use Illuminate\Http\Request;
class FormRequestStub extends Request
{
public function rules(): array
{
return [
'name' => 'required|string',
'email' => 'required|email',
];
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace Sunchayn\Nimbus\Tests\App\Modules\Routes\Extractors\Ast\Stubs;
use Illuminate\Http\Request;
class FormRequestWithInputConditionalStub extends Request
{
public function rules(): array
{
$rule = 'required|string';
$shouldAllowSomething = $this->boolean('something');
return [
'name' => $shouldAllowSomething ? 'required|string' : 'present|string',
'email' => "{$rule}|email",
];
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace Sunchayn\Nimbus\Tests\App\Modules\Routes\Extractors\Ast\Stubs;
use Illuminate\Http\Request;
class FormRequestWithVariablesStub extends Request
{
public function rules(): array
{
$rule = 'required|string';
return [
'name' => $rule,
'email' => "{$rule}|email",
];
}
}

View File

@@ -0,0 +1,134 @@
<?php
use Illuminate\Container\Container;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\In;
use Illuminate\Validation\Rules\NotIn;
use Illuminate\Validation\Rules\RequiredIf;
use Sunchayn\Nimbus\Tests\App\Modules\Routes\Extractors\Ast\Stubs\FormRequestStub;
use Sunchayn\Nimbus\Tests\App\Modules\Routes\Extractors\Stubs\StatusEnumStub;
class TestController
{
public function simple_call(Request $request): void
{
$validated = $request->validate([
'foobar' => 'required|string',
'foobaz' => ['required', 'integer'],
]);
}
public function simple_call_without_assignment(FormRequestStub $request)
{
$request->validate([
'foobar' => 'required|string',
'foobaz' => ['required', 'integer'],
]);
}
public function getting_rules_from_sub_method(Request $request): void
{
$validated = $request->validate($this->craftRules());
}
public function call_from_a_variable(Request $request)
{
$rules = [
'foobar' => 'required|string',
'foobaz' => ['required', 'integer'],
];
$validated = $request->validate($rules);
}
public function call_from_nested_variables(Request $request)
{
$rule = ['required', 'string', 'email'];
$rules = [
'foobar' => 'required|string',
'foobaz' => $rule,
];
$validated = $request->validate($rules);
}
public function call_with_rules_instances(Request $request)
{
$rules = [
'foobar' => [
'required',
Rule::in(1, 2, 3, 4),
new NotIn(3, 4),
new RequiredIf(fn () => '::value::'),
Rule::enum(StatusEnumStub::class),
],
'foobaz' => ['required', 'string', new In(1, 2)],
];
$validated = $request->validate($rules);
}
public function call_validateWithBag(Request $request)
{
$rules = [
'foobar' => [
'required',
'in:1, 2, 3, 4',
],
'foobaz' => ['required', 'string', 'email'],
];
$validated = $request->validateWithBag('foobaz', $rules);
}
public function no_validate_call(Request $request)
{
return response()->json('Hi');
}
public function call_validate_on_different_class(Container $container)
{
$container->validate([
'foobar' => [
'required',
'in:1, 2, 3, 4',
],
'foobaz' => ['required', 'string', 'email'],
]);
}
public function nested_methods_calls(Request $request)
{
$this->validateFormData($request);
return response()->json('Hi');
}
private function validateFormData(Request $request)
{
return $request->validate([
'foobaz' => ['required', 'string', 'email'],
]);
}
private function craftRules(): array
{
$rule = ['required', 'string', 'email'];
$interpolation = 'required|string';
$interpolation2 = '|max:200';
$fieldname = 'foobaz';
return [
'foobar' => 'required_with:'.$fieldname,
'foobarr' => 'present_with'.':'.'foobar',
'fooobazz' => "{$interpolation}|email{$interpolation2}",
'baz' => "{$interpolation}|present".'|'.'email',
'foobaz' => $rule,
];
}
}

View File

@@ -0,0 +1,144 @@
<?php
namespace Sunchayn\Nimbus\Tests\App\Modules\Routes\Extractors\Ast;
use Generator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\In;
use PhpParser\NodeTraverser;
use PhpParser\ParserFactory;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Sunchayn\Nimbus\Modules\Routes\Extractor\Ast\ConvertNodeToConcreteValue;
use Sunchayn\Nimbus\Modules\Routes\Extractor\Ast\ValidateCallVisitor;
use Sunchayn\Nimbus\Modules\Schemas\Collections\Ruleset;
use Sunchayn\Nimbus\Tests\App\Modules\Routes\Extractors\Stubs\StatusEnumStub;
#[CoversClass(ValidateCallVisitor::class)]
#[CoversClass(ConvertNodeToConcreteValue::class)]
class ValidateCallVisitorUnitTest extends TestCase
{
#[DataProvider('scenariosDataProvider')]
public function test_it_works(
string $methodName,
string $phpCode,
array $expectedRules,
): void {
// Arrange
// Parse the stub into AST
$parser = (new ParserFactory)->createForNewestSupportedVersion();
$ast = $parser->parse($phpCode);
$visitor = new ValidateCallVisitor($methodName);
$traverser = new NodeTraverser;
$traverser->addVisitor($visitor);
// Act
$traverser->traverse($ast);
// Assert
$this->assertEquals(Ruleset::fromLaravelRules($expectedRules), $visitor->getRules());
}
public static function scenariosDataProvider(): Generator
{
yield 'simple call' => [
'methodName' => 'simple_call',
'phpCode' => file_get_contents(__DIR__.'/Stubs/controller.stub.php'),
'expectedRules' => [
'foobar' => 'required|string',
'foobaz' => ['required', 'integer'],
],
];
yield 'simple call without variable assignment' => [
'methodName' => 'simple_call_without_assignment',
'phpCode' => file_get_contents(__DIR__.'/Stubs/controller.stub.php'),
'expectedRules' => [
'foobar' => 'required|string',
'foobaz' => ['required', 'integer'],
],
];
yield 'rules from sub method, concatenation, and interpolation' => [
'methodName' => 'getting_rules_from_sub_method',
'phpCode' => file_get_contents(__DIR__.'/Stubs/controller.stub.php'),
'expectedRules' => [
'foobar' => 'required_with:foobaz',
'foobarr' => 'present_with:foobar',
'fooobazz' => 'required|string|email|max:200',
'baz' => 'required|string|present|email',
'foobaz' => ['required', 'string', 'email'],
],
];
yield 'call from a variable' => [
'methodName' => 'call_from_a_variable',
'phpCode' => file_get_contents(__DIR__.'/Stubs/controller.stub.php'),
'expectedRules' => [
'foobar' => 'required|string',
'foobaz' => ['required', 'integer'],
],
];
yield 'call from nested variables' => [
'methodName' => 'call_from_nested_variables',
'phpCode' => file_get_contents(__DIR__.'/Stubs/controller.stub.php'),
'expectedRules' => [
'foobar' => 'required|string',
'foobaz' => ['required', 'string', 'email'],
],
];
yield 'call with rules instances' => [
'methodName' => 'call_with_rules_instances',
'phpCode' => file_get_contents(__DIR__.'/Stubs/controller.stub.php'),
'expectedRules' => [
'foobar' => [
'required',
Rule::in(1, 2, 3, 4),
Rule::notIn(3, 4),
null, // <- RequiredIf cannot be resolved with a closure.
Rule::enum(StatusEnumStub::class),
],
'foobaz' => ['required', 'string', new In(1, 2)],
],
];
yield 'call with validateWithBag' => [
'methodName' => 'call_validateWithBag',
'phpCode' => file_get_contents(__DIR__.'/Stubs/controller.stub.php'),
'expectedRules' => [
'foobar' => [
'required',
'in:1, 2, 3, 4',
],
'foobaz' => ['required', 'string', 'email'],
],
];
yield 'no validate call' => [
'methodName' => 'no_validate_call',
'phpCode' => file_get_contents(__DIR__.'/Stubs/controller.stub.php'),
'expectedRules' => [],
];
yield 'calling validate on a non-request class' => [
'methodName' => 'call_validate_on_different_class',
'phpCode' => file_get_contents(__DIR__.'/Stubs/controller.stub.php'),
'expectedRules' => [],
];
yield 'nested methods call' => [
'methodName' => 'nested_methods_calls',
'phpCode' => file_get_contents(__DIR__.'/Stubs/controller.stub.php'),
'expectedRules' => [], // <- Currently this is not supported.
];
}
}

View File

@@ -0,0 +1,453 @@
<?php
namespace Sunchayn\Nimbus\Tests\App\Modules\Routes\Extractors;
use Generator;
use Illuminate\Http\Request;
use Mockery;
use PhpParser\NodeTraverser;
use PhpParser\Parser\Php8;
use PhpParser\ParserFactory;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\PreserveGlobalState;
use PHPUnit\Framework\Attributes\RunInSeparateProcess;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
use ReflectionNamedType;
use ReflectionParameter;
use Sunchayn\Nimbus\Modules\Routes\Extractor\Ast\RulesMethodVisitor;
use Sunchayn\Nimbus\Modules\Routes\Extractor\Strategies\FormRequestExtractorStrategy;
use Sunchayn\Nimbus\Modules\Routes\ValueObjects\ExtractableRoute;
use Sunchayn\Nimbus\Modules\Routes\ValueObjects\RulesExtractionError;
use Sunchayn\Nimbus\Modules\Schemas\Builders\SchemaBuilder;
use Sunchayn\Nimbus\Modules\Schemas\Collections\Ruleset;
use Sunchayn\Nimbus\Modules\Schemas\ValueObjects\Schema;
use Sunchayn\Nimbus\Modules\Schemas\ValueObjects\SchemaProperty;
use Sunchayn\Nimbus\Tests\App\Modules\Routes\Extractors\Stubs\FormRequestStub;
use Sunchayn\Nimbus\Tests\App\Modules\Routes\Extractors\Stubs\FormRequestWithDifferentRulesStub;
use Sunchayn\Nimbus\Tests\App\Modules\Routes\Extractors\Stubs\FormRequestWithExceptionStub;
use Sunchayn\Nimbus\Tests\App\Modules\Routes\Extractors\Stubs\FormRequestWithoutRulesStub;
#[CoversClass(FormRequestExtractorStrategy::class)]
#[CoversClass(RulesExtractionError::class)]
class FormRequestExtractorStrategyUnitTest extends TestCase
{
private SchemaBuilder $schemaBuilderMock;
private FormRequestExtractorStrategy $strategy;
protected function setUp(): void
{
parent::setUp();
$this->schemaBuilderMock = Mockery::mock(SchemaBuilder::class);
$this->strategy = new FormRequestExtractorStrategy($this->schemaBuilderMock);
}
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
#[DataProvider('matchesProvider')]
public function test_it_matches_correctly(
array $parameterReflectionData,
bool $expected
): void {
// Arrange
$parameters = array_map(
fn (array $reflectionData) => $this->makeReflectionParameter($reflectionData),
$parameterReflectionData
);
$route = $this->makeExtractableRoute($parameters);
// Act
$actual = $this->strategy->matches($route);
// Assert
$this->assertEquals($expected, $actual);
}
public static function matchesProvider(): Generator
{
yield 'form request parameter' => [
'parameterReflectionData' => [
['hasType' => true, 'getType' => FormRequestStub::class], // <- ReflectionParameter methods return values.
],
'expected' => true,
];
yield 'base request parameter' => [
'parameterReflectionData' => [
['hasType' => true, 'getType' => Request::class],
],
'expected' => false,
];
yield 'parameter without type' => [
'parameterReflectionData' => [
['hasType' => false, 'getType' => null],
],
'expected' => false,
];
yield 'parameter with non-named type' => [
'parameterReflectionData' => [
['hasType' => true, 'getType' => null],
],
'expected' => false,
];
yield 'parameter not request subclass' => [
'parameterReflectionData' => [
['hasType' => true, 'getType' => \stdClass::class],
],
'expected' => false,
];
yield 'no parameters' => [
'parameterReflectionData' => [],
'expected' => false,
];
yield 'multiple parameters with form request' => [
'parameterReflectionData' => [
['hasType' => true, 'getType' => \stdClass::class],
['hasType' => true, 'getType' => FormRequestStub::class],
],
'expected' => true,
];
yield 'multiple parameters without form request' => [
'parameterReflectionData' => [
['hasType' => true, 'getType' => \stdClass::class],
['hasType' => true, 'getType' => 'string'],
],
'expected' => false,
];
yield 'form request after base request' => [
'parameterReflectionData' => [
['hasType' => true, 'getType' => Request::class],
['hasType' => true, 'getType' => FormRequestStub::class],
],
'expected' => false, // <- stops at base Request
];
}
#[DataProvider('extractProvider')]
public function test_it_extracts_schema_from_form_request(
string $formRequestClass,
Ruleset $expectedRules
): void {
// Arrange
$parameter = $this->makeReflectionParameter([
'getType' => $formRequestClass,
'hasType' => true,
]);
$route = $this->makeExtractableRoute([$parameter]);
// Anticipate
$responseSchemaStub = new Schema(properties: [
new SchemaProperty(
name: '::property::',
),
]);
$this->schemaBuilderMock
->shouldReceive('buildSchemaFromRuleset')
->withAnyArgs()
->andReturn($responseSchemaStub)
->once();
// Act
$schema = $this->strategy->extract($route);
// Assert
$this->assertSame($responseSchemaStub, $schema);
$this
->schemaBuilderMock
->shouldHaveReceived('buildSchemaFromRuleset')
->withArgs(function (Ruleset $ruleset, ?RulesExtractionError $rulesExtractionError) use ($expectedRules) {
$this->assertEquals(
$expectedRules->all(),
$ruleset->all(),
);
$this->assertNull($rulesExtractionError);
return true;
})
->once();
}
public static function extractProvider(): Generator
{
yield 'form request with rules' => [
'formRequestClass' => FormRequestStub::class,
'expectedRules' => Ruleset::make([
'name' => ['required', 'string'],
'email' => ['required', 'email'],
]),
];
yield 'form request with different rules' => [
'formRequestClass' => FormRequestWithDifferentRulesStub::class,
'expectedRules' => Ruleset::make([
'title' => ['required'],
'content' => ['nullable', 'string'],
]),
];
}
#[RunInSeparateProcess] // <- Having overload Mock here, let's not leak it.
#[PreserveGlobalState(false)]
public function test_it_handles_exception_when_calling_rules_method(): void
{
// Arrange
$parameter = $this->makeReflectionParameter([
'hasType' => true,
'getType' => FormRequestWithExceptionStub::class,
]);
$route = $this->makeExtractableRoute([$parameter]);
$visitorMock = Mockery::mock('overload:'.RulesMethodVisitor::class);
$nodeTraverserMock = Mockery::mock('overload:'.NodeTraverser::class);
$parserFactoryMock = Mockery::mock('overload:'.ParserFactory::class);
$parserMock = Mockery::mock('overload:'.Php8::class);
// Anticipate
$parserMock->shouldReceive('parse')->andReturn([]);
$parserFactoryMock->shouldReceive('createForNewestSupportedVersion')->andReturn($parserMock);
$nodeTraverserMock
->shouldReceive('addVisitor')
->withAnyArgs()
->with(Mockery::type(RulesMethodVisitor::class))
->ordered()
->once();
// TODO [Test] This is not a conclusive assert at the moment, it doesn't ensure the AST is extracted correctly.
$nodeTraverserMock
->shouldReceive('traverse')
->with(Mockery::type('array'))
->ordered()
->once();
$responseSchemaStub = Schema::empty();
$expectedRuleset = Ruleset::make([
'name' => ['required', 'string'],
'email' => ['required', 'email'],
]);
$visitorMock
->shouldReceive('getRules')
->ordered()
->andReturn($expectedRuleset);
$this
->schemaBuilderMock
->shouldReceive('buildSchemaFromRuleset')
->withAnyArgs()
->andReturn($responseSchemaStub);
// Act
$schema = $this->strategy->extract($route);
// Assert
$this->assertSame($responseSchemaStub, $schema);
$this
->schemaBuilderMock
->shouldHaveReceived('buildSchemaFromRuleset')
->withArgs(function (Ruleset $ruleset, ?RulesExtractionError $rulesExtractionError) use ($expectedRuleset) {
$this->assertEquals(
$expectedRuleset->all(),
$ruleset->all(),
);
$this->assertNotNull($rulesExtractionError);
$stubRequestClassName = (new ReflectionClass(FormRequestWithExceptionStub::class))->getFileName();
// Note the error will be coming from `FormRequestWithExceptionStub`.
$this->assertEquals(
<<<HTML
<b>Cannot access request context</b><br />
<small>{$stubRequestClassName}::11</small>
<p class="text-xs">[trace]</p>
HTML,
// TODO [Test] Test is not the right place to test this method.
// TODO [Test] Figure a way to fully test this properly. Due to final methods we cannot assert the trace properly.
preg_replace('#(<p\b[^>]*>).*?(</p>)#si', '$1[trace]$2', $rulesExtractionError->toHtml()),
);
return true;
})
->once();
}
public function test_it_extracts_from_first_form_request_with_multiple_parameters(): void
{
// Arrange
$regularParam = $this->makeReflectionParameter([
'hasType' => true,
'getType' => \stdClass::class,
]);
$formRequestParam = $this->makeReflectionParameter([
'hasType' => true,
'getType' => FormRequestStub::class,
]);
$route = $this->makeExtractableRoute([$regularParam, $formRequestParam]);
$expectedRuleset = Ruleset::make([
'name' => ['required', 'string'],
'email' => ['required', 'email'],
]);
// Anticipate
$responseSchemaStub = new Schema(properties: [
new SchemaProperty(
name: '::property::',
),
]);
$this
->schemaBuilderMock
->shouldReceive('buildSchemaFromRuleset')
->withAnyArgs()
->andReturn($responseSchemaStub);
// Act
$schema = $this->strategy->extract($route);
// Assert
$this->assertSame($responseSchemaStub, $schema);
$this
->schemaBuilderMock
->shouldHaveReceived('buildSchemaFromRuleset')
->withArgs(function (Ruleset $ruleset, ?RulesExtractionError $rulesExtractionError) use ($expectedRuleset) {
$this->assertEquals(
$expectedRuleset->all(),
$ruleset->all(),
);
$this->assertNull($rulesExtractionError);
return true;
})
->once();
}
#[DataProvider('emptySchemaEarlyReturnProvider')]
public function test_it_returns_early_with_empty_schema(array $parameterReflectionData): void
{
// Arrange
$parameters = array_map(
fn ($config) => $this->makeReflectionParameter($config),
$parameterReflectionData
);
$route = $this->makeExtractableRoute($parameters);
// Anticipate
$this->schemaBuilderMock->shouldNotReceive('buildSchemaFromRuleset');
// Act
$schema = $this->strategy->extract($route);
// Assert
$this->assertTrue($schema->isEmpty());
}
public static function emptySchemaEarlyReturnProvider(): Generator
{
yield 'no form request parameter' => [
'parameterReflectionData' => [
['hasType' => true, 'getType' => \stdClass::class],
],
];
yield 'parameter type is not named type' => [
'parameterReflectionData' => [
['hasType' => true, 'getType' => null],
],
];
yield 'form request without rules method' => [
'parameterReflectionData' => [
['hasType' => true, 'getType' => FormRequestWithoutRulesStub::class],
],
];
yield 'no parameters' => [
'parameterReflectionData' => [],
];
}
/*
* Generators.
*/
private function makeExtractableRoute(array $parameters): ExtractableRoute
{
return new ExtractableRoute(
parameters: $parameters,
codeParser: fn () => 'noop',
);
}
/**
* @param array{hasType: bool, getType: string} $config
*/
private function makeReflectionParameter(array $config): ReflectionParameter
{
$reflectionParameter = $this->createMock(ReflectionParameter::class);
$reflectionParameter->method('hasType')->willReturn($config['hasType']);
if ($config['getType'] !== null) {
$type = $this->createMock(ReflectionNamedType::class);
$type->method('getName')->willReturn($config['getType']);
$reflectionParameter->method('getType')->willReturn($type);
return $reflectionParameter;
}
$reflectionParameter->method('getType')->willReturn(null);
return $reflectionParameter;
}
}

View File

@@ -0,0 +1,249 @@
<?php
namespace Sunchayn\Nimbus\Tests\App\Modules\Routes\Extractors;
use Generator;
use Illuminate\Support\Arr;
use Mockery;
use PhpParser\NodeTraverser;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\PreserveGlobalState;
use PHPUnit\Framework\Attributes\RunInSeparateProcess;
use PHPUnit\Framework\TestCase;
use Sunchayn\Nimbus\Modules\Routes\Extractor\Ast\ValidateCallVisitor;
use Sunchayn\Nimbus\Modules\Routes\Extractor\Strategies\InlineRequestValidatorExtractorStrategy;
use Sunchayn\Nimbus\Modules\Routes\ValueObjects\ExtractableRoute;
use Sunchayn\Nimbus\Modules\Routes\ValueObjects\RulesExtractionError;
use Sunchayn\Nimbus\Modules\Schemas\Builders\SchemaBuilder;
use Sunchayn\Nimbus\Modules\Schemas\Collections\Ruleset;
use Sunchayn\Nimbus\Modules\Schemas\ValueObjects\Schema;
use Sunchayn\Nimbus\Modules\Schemas\ValueObjects\SchemaProperty;
#[CoversClass(InlineRequestValidatorExtractorStrategy::class)]
class InlineRequestValidatorExtractorStrategyUnitTest extends TestCase
{
private SchemaBuilder&Mockery\MockInterface $schemaBuilderMock;
private InlineRequestValidatorExtractorStrategy $strategy;
protected function setUp(): void
{
parent::setUp();
$this->schemaBuilderMock = Mockery::mock(SchemaBuilder::class);
$this->strategy = new InlineRequestValidatorExtractorStrategy($this->schemaBuilderMock);
}
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
#[DataProvider('matchesProvider')]
public function test_it_matches_correctly(
?string $methodName,
bool $expected
): void {
// Arrange
$route = $this->makeExtractableRoute(
methodName: $methodName,
codeParser: fn () => []
);
// Act
$actual = $this->strategy->matches($route);
// Assert
$this->assertEquals($expected, $actual);
}
public static function matchesProvider(): Generator
{
yield 'route with method name' => [
'methodName' => 'store',
'expected' => true,
];
yield 'route with different method name' => [
'methodName' => 'update',
'expected' => true,
];
yield 'route without method name' => [
'methodName' => null,
'expected' => false,
];
}
#[RunInSeparateProcess] // <- Having overload Mock here, let's not leak it.
#[PreserveGlobalState(false)]
public function test_it_extracts_schema_from_inline_validation(): void
{
// Arrange
$responseSchemaStub = new Schema(properties: [
new SchemaProperty(name: 'name'),
new SchemaProperty(name: 'email'),
]);
$ast = ['node' => 'value'];
$methodName = Arr::random(
[
'store',
'get',
'edit',
],
);
$route = $this->makeExtractableRoute(
methodName: $methodName,
codeParser: fn () => $ast
);
$visitorMock = Mockery::mock('overload:'.ValidateCallVisitor::class);
$nodeTraverserMock = Mockery::mock('overload:'.NodeTraverser::class);
// Anticipate
$visitorMock
->shouldReceive('__construct')
->once()
->withArgs(function ($methodArg) use ($methodName) {
$this->assertEquals(
$methodName,
$methodArg,
);
return true;
});
$nodeTraverserMock->shouldReceive('addVisitor')
->with(Mockery::type(ValidateCallVisitor::class))
->ordered()
->once();
$nodeTraverserMock->shouldReceive('traverse')->with($ast)->ordered()->once();
$expectedRuleset = Ruleset::fromLaravelRules([
'name' => 'required|string',
'email' => 'required|email',
]);
$visitorMock->shouldReceive('getRules')->ordered()->andReturn($expectedRuleset);
$this->schemaBuilderMock
->shouldReceive('buildSchemaFromRuleset')
->withAnyArgs()
->andReturn($responseSchemaStub)
->once();
// Act
$schema = $this->strategy->extract($route);
// Assert
$this->assertSame($responseSchemaStub, $schema);
$this
->schemaBuilderMock
->shouldHaveReceived('buildSchemaFromRuleset')
->withArgs(function (Ruleset $ruleset, ?RulesExtractionError $rulesExtractionError = null) use ($expectedRuleset) {
$this->assertEquals(
$expectedRuleset->all(),
$ruleset->all(),
);
$this->assertNull($rulesExtractionError);
return true;
})
->once();
}
public function test_it_returns_empty_schema_when_route_does_not_match(): void
{
// Arrange
$route = $this->makeExtractableRoute(
methodName: null,
codeParser: fn () => []
);
// Anticipate
$this->schemaBuilderMock->shouldNotReceive('buildSchemaFromRuleset');
// Act
$schema = $this->strategy->extract($route);
// Assert
$this->assertTrue($schema->isEmpty());
}
public function test_it_builds_schema_with_empty_rules_when_no_validation_found(): void
{
// Arrange
$route = $this->makeExtractableRoute(
methodName: 'store',
codeParser: fn () => [] // <- Empty AST, no `->validate()` (or other eligible methods) calls.
);
$responseSchemaStub = Schema::empty();
// Anticipate
$this->schemaBuilderMock
->shouldReceive('buildSchemaFromRuleset')
->withAnyArgs()
->andReturn($responseSchemaStub)
->once();
// Act
$schema = $this->strategy->extract($route);
// Assert
$this->assertSame($responseSchemaStub, $schema);
$this
->schemaBuilderMock
->shouldHaveReceived('buildSchemaFromRuleset')
->withArgs(function (Ruleset $ruleset, ?RulesExtractionError $rulesExtractionError = null) {
$this->assertEquals(
[],
$ruleset->all(),
);
$this->assertNull($rulesExtractionError);
return true;
})
->once();
}
/*
* Generators.
*/
private function makeExtractableRoute(?string $methodName, callable $codeParser): ExtractableRoute
{
return new ExtractableRoute(
parameters: [], // <- Not needed in this strategy.
codeParser: $codeParser,
methodName: $methodName,
);
}
}

View File

@@ -0,0 +1,149 @@
<?php
namespace Sunchayn\Nimbus\Tests\App\Modules\Routes\Extractors;
use Illuminate\Container\Container;
use Illuminate\Support\Arr;
use Mockery;
use Mockery\MockInterface;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use Sunchayn\Nimbus\Modules\Routes\Extractor\SchemaExtractor;
use Sunchayn\Nimbus\Modules\Routes\Extractor\Strategies\ExtractorStrategyContract;
use Sunchayn\Nimbus\Modules\Routes\Extractor\Strategies\FormRequestExtractorStrategy;
use Sunchayn\Nimbus\Modules\Routes\Extractor\Strategies\InlineRequestValidatorExtractorStrategy;
use Sunchayn\Nimbus\Modules\Routes\ValueObjects\ExtractableRoute;
use Sunchayn\Nimbus\Modules\Schemas\ValueObjects\Schema;
#[CoversClass(SchemaExtractor::class)]
class SchemaExtractorUnitTest extends TestCase
{
public function test_it_initialize_extractor_correctly(): void
{
// Arrange
$formRequestExtractorStrategyMock = Mockery::mock(FormRequestExtractorStrategy::class);
$inlineRequestValidatorExtractorStrategyMock = Mockery::mock(InlineRequestValidatorExtractorStrategy::class);
$containerMock = Mockery::mock(Container::class);
$this->swapMocksInDIContainer(
$containerMock,
implementations: [
FormRequestExtractorStrategy::class => $formRequestExtractorStrategyMock,
InlineRequestValidatorExtractorStrategy::class => $inlineRequestValidatorExtractorStrategyMock,
],
);
// Act
$schemaExtractor = new SchemaExtractor($containerMock);
// Assert
$strategies = invade($schemaExtractor)->strategies;
$this->assertCount(2, $strategies);
$this->assertSame($formRequestExtractorStrategyMock, $strategies[0]);
$this->assertSame($inlineRequestValidatorExtractorStrategyMock, $strategies[1]);
}
public function test_it_extracts_using_the_matching_strategy(): void
{
// Arrange
$containerMock = Mockery::mock(
Container::class,
function (MockInterface $mock) {
// Ignore existent strategies.
$mock->shouldReceive('make')->withAnyArgs()->andReturnNull();
},
);
[$matchingStrategyMock, $nonMatchingStrategyMock] = $this->makeMatchingStrategies();
// Make schema extractor with the stub strategies.
$schemaExtractor = new SchemaExtractor($containerMock);
invade($schemaExtractor)->strategies = Arr::shuffle([
$matchingStrategyMock,
$nonMatchingStrategyMock,
]);
$route = new ExtractableRoute(
parameters: [],
codeParser: fn () => 'noop',
);
// Act
$schemaExtractor->extract($route);
// Assert
$matchingStrategyMock
->shouldHaveReceived('extract')
->withArgs(function (ExtractableRoute $routeArg) use ($route) {
$this->assertSame($route, $routeArg);
return true;
})
->once();
$nonMatchingStrategyMock->shouldNotHaveReceived('extract');
}
/*
* Mocks.
*/
private function swapMocksInDIContainer(mixed $containerMock, array $implementations): void
{
foreach ($implementations as $abstract => $implementation) {
$containerMock
->shouldReceive('make')
->with($abstract)
->once()
->andReturn($implementation);
}
}
/*
* Generators.
*/
private function makeMatchingStrategies(): array
{
$nonMatchingStrategy = new class implements ExtractorStrategyContract
{
public function matches(ExtractableRoute $route): bool
{
return false;
}
public function extract(ExtractableRoute $route): Schema
{
return Schema::empty();
}
};
$matchingStrategy = new class implements ExtractorStrategyContract
{
public function matches(ExtractableRoute $route): bool
{
return true;
}
public function extract(ExtractableRoute $route): Schema
{
return Schema::empty();
}
};
return [
Mockery::mock($matchingStrategy)->makePartial(),
Mockery::mock($nonMatchingStrategy)->makePartial(),
];
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace Sunchayn\Nimbus\Tests\App\Modules\Routes\Extractors\Stubs;
use Illuminate\Http\Request;
class FormRequestStub extends Request
{
public function rules(): array
{
return [
'name' => 'required|string',
'email' => 'required|email',
];
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace Sunchayn\Nimbus\Tests\App\Modules\Routes\Extractors\Stubs;
use Illuminate\Http\Request;
class FormRequestWithDifferentRulesStub extends Request
{
public function rules(): array
{
return [
'title' => 'required',
'content' => 'nullable|string',
];
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace Sunchayn\Nimbus\Tests\App\Modules\Routes\Extractors\Stubs;
use Illuminate\Http\Request;
class FormRequestWithExceptionStub extends Request
{
public function rules(): array
{
throw new \RuntimeException('Cannot access request context');
}
}

View File

@@ -0,0 +1,10 @@
<?php
namespace Sunchayn\Nimbus\Tests\App\Modules\Routes\Extractors\Stubs;
use Illuminate\Http\Request;
class FormRequestWithoutRulesStub extends Request
{
// No rules method
}

View File

@@ -0,0 +1,10 @@
<?php
namespace Sunchayn\Nimbus\Tests\App\Modules\Routes\Extractors\Stubs;
enum StatusEnumStub: string
{
case INACTIVE = 'inactive';
case ACTIVE = 'active';
}

View File

@@ -0,0 +1,265 @@
<?php
namespace Sunchayn\Nimbus\Tests\App\Modules\Routes\Factories;
use Closure;
use Generator;
use Illuminate\Routing\Route;
use Mockery;
use PhpParser\Parser\Php8;
use PhpParser\ParserFactory;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\PreserveGlobalState;
use PHPUnit\Framework\Attributes\RunInSeparateProcess;
use PHPUnit\Framework\TestCase;
use ReflectionParameter;
use RuntimeException;
use Sunchayn\Nimbus\Modules\Routes\Exceptions\InvalidRouteDefinitionException;
use Sunchayn\Nimbus\Modules\Routes\Exceptions\RouteExtractionException;
use Sunchayn\Nimbus\Modules\Routes\Factories\ExtractableRouteFactory;
use Sunchayn\Nimbus\Tests\App\Modules\Routes\Factories\Stubs\ExtractableControllerStub;
use Sunchayn\Nimbus\Tests\App\Modules\Routes\Factories\Stubs\RequestStub;
#[CoversClass(ExtractableRouteFactory::class)]
#[CoversClass(InvalidRouteDefinitionException::class)]
class ExtractableRouteFactoryUnitTest extends TestCase
{
private ExtractableRouteFactory $factory;
protected function setUp(): void
{
parent::setUp();
$this->factory = new ExtractableRouteFactory;
}
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
public function test_it_creates_extractable_route_from_valid_controller_route(): void
{
// Arrange
$route = new Route(
['GET'],
'/users',
['uses' => ExtractableControllerStub::class.'@index'],
);
// Act
$extractableRoute = $this->factory->fromLaravelRoute($route);
// Assert
$this->assertEquals('index', $extractableRoute->methodName);
$this->assertEquals(
ExtractableControllerStub::class,
$extractableRoute->controllerClass,
);
$this->assertEquals('index', $extractableRoute->controllerMethod);
$this->assertEquals([], $extractableRoute->parameters);
$this->assertParsedCodeEquals(
__DIR__.'/Stubs/ExtractableControllerStub.php',
$extractableRoute->codeParser,
);
}
public function test_it_extracts_method_parameters_correctly(): void
{
// Arrange
$route = new Route(
['POST'],
'/users',
action: ['uses' => ExtractableControllerStub::class.'@store'] // <- this method has parameters
);
// Act
$extractableRoute = $this->factory->fromLaravelRoute($route);
// Assert
$this->assertCount(1, $extractableRoute->parameters);
$this->assertInstanceOf(ReflectionParameter::class, $extractableRoute->parameters[0]);
$this->assertSame('request', $extractableRoute->parameters[0]->getName());
$this->assertSame(RequestStub::class, $extractableRoute->parameters[0]->getType()->getName());
}
public function test_it_returns_empty_route_for_closure_based_routes(): void
{
// Arrange
$route = new Route(
['POST'],
'/closure',
action: ['uses' => fn () => 'response'],
);
// Act
$extractableRoute = $this->factory->fromLaravelRoute($route);
// Assert
$this->assertEmpty($extractableRoute->parameters);
$this->assertEmpty(($extractableRoute->codeParser)());
$this->assertNull($extractableRoute->methodName);
$this->assertNull($extractableRoute->controllerClass);
$this->assertNull($extractableRoute->controllerMethod);
}
#[DataProvider('invalidRoutesDataProvider')]
public function test_it_breaks_correctly_for_invalid_routes(
Route $route,
string $expectedException,
string $expectedExceptionMessage,
string $expectedControllerClass,
string $expectedControllerMethod,
string $expectedSuggestedSolution,
string $expectedIgnoreData,
): void {
// Act
try {
$this->factory->fromLaravelRoute($route);
} catch (RouteExtractionException $actualException) {
}
// Assert
$this->assertInstanceOf($expectedException, $actualException);
$this->assertEquals(
$expectedExceptionMessage,
$actualException->getMessage(),
);
$this->assertEquals(
$expectedSuggestedSolution,
$actualException->getSuggestedSolution(),
);
$this->assertEquals(
$expectedIgnoreData,
$actualException->getIgnoreData(),
);
$this->assertEquals(
[
'uri' => $route->uri(),
'methods' => $route->methods(),
'controllerClass' => $expectedControllerClass,
'controllerMethod' => $expectedControllerMethod,
],
$actualException->getRouteContext(),
);
}
public static function invalidRoutesDataProvider(): Generator
{
yield 'controller class is empty' => [
'route' => new Route(methods: ['GET'], uri: '/invalid', action: ['uses' => '@method']),
'expectedException' => InvalidRouteDefinitionException::class,
'expectedExceptionMessage' => "Malformed `uses` statement for route 'invalid'.",
'expectedControllerClass' => '[unspecified]',
'expectedControllerMethod' => 'method',
'expectedSuggestedSolution' => 'Make sure the `uses` statement is properly formatted `{controllerClass}@{controllerMethod}`. If it is an invokable controller then it must not have the `@` suffix.',
'expectedIgnoreData' => 'invalid|["GET","HEAD"]',
];
yield 'controller method is empty' => [
'route' => new Route(methods: ['GET'], uri: '/invalid-2', action: ['uses' => 'SomeController@']),
'expectedException' => InvalidRouteDefinitionException::class,
'expectedExceptionMessage' => "Malformed `uses` statement for route 'invalid-2'.",
'expectedControllerClass' => 'SomeController',
'expectedControllerMethod' => '[unspecified]',
'expectedSuggestedSolution' => 'Make sure the `uses` statement is properly formatted `{controllerClass}@{controllerMethod}`. If it is an invokable controller then it must not have the `@` suffix.',
'expectedIgnoreData' => 'invalid-2|["GET","HEAD"]',
];
yield 'controller class doesnt exist' => [
'route' => new Route(methods: ['GET'], uri: '/invalid-3', action: ['uses' => 'App\Http\Controllers\NonExistentController@index']),
'expectedException' => InvalidRouteDefinitionException::class,
'expectedExceptionMessage' => "Controller method 'index' not found in class 'App\Http\Controllers\NonExistentController' for route 'invalid-3'.",
'expectedControllerClass' => 'App\Http\Controllers\NonExistentController',
'expectedControllerMethod' => 'index',
'expectedSuggestedSolution' => "Check that the method 'index' exists in the 'App\Http\Controllers\NonExistentController' class. This usually indicates an incorrect route definition in your routes file.",
'expectedIgnoreData' => 'invalid-3|["GET","HEAD"]',
];
yield 'controller method doesnt exist' => [
'route' => new Route(methods: ['GET'], uri: '/invalid-4', action: ['uses' => ExtractableControllerStub::class.'@nonExistentMethod']),
'expectedException' => InvalidRouteDefinitionException::class,
'expectedExceptionMessage' => sprintf("Controller method 'nonExistentMethod' not found in class '%s' for route 'invalid-4'.", ExtractableControllerStub::class),
'expectedControllerClass' => ExtractableControllerStub::class,
'expectedControllerMethod' => 'nonExistentMethod',
'expectedSuggestedSolution' => sprintf("Check that the method 'nonExistentMethod' exists in the '%s' class. This usually indicates an incorrect route definition in your routes file.", ExtractableControllerStub::class),
'expectedIgnoreData' => 'invalid-4|["GET","HEAD"]',
];
}
#[RunInSeparateProcess] // <- Having overload Mock here, let's not leak it.
#[PreserveGlobalState(false)]
public function test_it_handles_file_read_errors_gracefully(): void
{
// Arrange
$route = new Route(
['GET'],
'/users',
action: ['uses' => ExtractableControllerStub::class.'@store']
);
$extractableRoute = $this->factory->fromLaravelRoute($route);
$parserFactoryMock = Mockery::mock('overload:'.ParserFactory::class);
$parserMock = Mockery::mock('overload:'.Php8::class);
// Anticipate
$parserMock->shouldReceive('parse')->andThrow(new RuntimeException('Cannot parse.'));
$parserFactoryMock->shouldReceive('createForNewestSupportedVersion')->andReturn($parserMock);
// Act
$result = ($extractableRoute->codeParser)();
// Assert
$this->assertNull($result);
}
/*
* Asserts.
*/
private function assertParsedCodeEquals(string $expected, Closure $parser): void
{
$actualParsedCode = $parser();
$expectedParsedCode = file_get_contents($expected);
$this->assertEquals(
(new ParserFactory)->createForNewestSupportedVersion()->parse($expectedParsedCode),
$actualParsedCode,
);
}
}

View File

@@ -0,0 +1,10 @@
<?php
namespace Sunchayn\Nimbus\Tests\App\Modules\Routes\Factories\Stubs;
class ExtractableControllerStub
{
public function index(): void {}
public function store(RequestStub $request): void {}
}

View File

@@ -0,0 +1,7 @@
<?php
namespace Sunchayn\Nimbus\Tests\App\Modules\Routes\Factories\Stubs;
use Illuminate\Http\Request;
class RequestStub extends Request {}

View File

@@ -0,0 +1,307 @@
<?php
namespace Sunchayn\Nimbus\Tests\App\Modules\Routes\Services;
use Carbon\CarbonImmutable;
use Generator;
use Illuminate\Routing\Route;
use Illuminate\Support\Facades\File;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use Sunchayn\Nimbus\Modules\Routes\Services\IgnoredRoutesService;
use Sunchayn\Nimbus\Tests\TestCase;
#[CoversClass(IgnoredRoutesService::class)]
class IgnoredRoutesServiceFunctionalTest extends TestCase
{
private const FILE_PATH = 'vendor/sunchayn/nimbus/storage/ignored_routes.json';
protected function setUp(): void
{
parent::setUp();
File::delete(base_path(self::FILE_PATH));
File::deleteDirectory(dirname(base_path(self::FILE_PATH)));
}
public function test_it_has_no_ignored_routes_initially(): void
{
// Arrange
$service = resolve(IgnoredRoutesService::class);
// Act
$hasIgnoredRoutes = $service->hasIgnoredRoutes();
// Assert
$this->assertFalse($hasIgnoredRoutes);
}
public function test_it_adds_route_to_ignored_list(): void
{
// Arrange
$service = resolve(IgnoredRoutesService::class);
$route = $this->createMockRoute(uri: '/api/users', methods: ['GET', 'POST']);
// Act
$service->add(
uri: $route->uri(),
methods: $route->methods(),
reason: 'Test reason'
);
// Assert
$this->assertTrue($service->hasIgnoredRoutes());
$this->assertTrue($service->isIgnored($route));
}
public function test_it_persists_ignored_routes_to_file_on_destruct(): void
{
// Arrange
CarbonImmutable::setTestNow(CarbonImmutable::now());
$service = resolve(IgnoredRoutesService::class);
$service->add(uri: '/api/users', methods: ['GET'], reason: 'Test reason');
$filePath = base_path(self::FILE_PATH);
// Act
$service->__destruct();
// Assert
$this->assertTrue(File::exists($filePath));
$content = json_decode(File::get($filePath), true);
$this->assertEquals(
[
'/api/users' => [
'methods' => ['GET'],
'reason' => 'Test reason',
'ignored_at' => CarbonImmutable::now()->toISOString(),
],
],
$content,
);
}
public function test_it_overwrites_existing_ignored_route(): void
{
// Arrange
$service = resolve(IgnoredRoutesService::class);
$service->add(uri: '/api/users', methods: ['GET'], reason: 'First reason');
$service->add(uri: '/api/users', methods: ['POST'], reason: 'Second reason');
$filePath = base_path(self::FILE_PATH);
// Act
$service->__destruct();
// Assert
$this->assertTrue(File::exists($filePath));
$content = json_decode(File::get($filePath), true);
$this->assertEquals(['POST'], $content['/api/users']['methods']);
$this->assertEquals('Second reason', $content['/api/users']['reason']);
}
public function test_it_does_not_write_to_file_if_not_dirty(): void
{
// Arrange
$service = resolve(IgnoredRoutesService::class);
$filePath = base_path(self::FILE_PATH);
// Act
$service->__destruct();
// Assert
$this->assertFalse(File::exists($filePath));
}
#[DataProvider('routeIgnoredProvider')]
public function test_it_checks_if_route_is_ignored(
array $ignoredMethods,
array $routeMethods,
bool $expectedIgnored
): void {
// Arrange
$service = resolve(IgnoredRoutesService::class);
$route = $this->createMockRoute('/api/users', $routeMethods);
$service->add(uri: $route->uri(), methods: $ignoredMethods, reason: 'Test');
// Act
$isIgnored = $service->isIgnored($route);
// Assert
$this->assertEquals($expectedIgnored, $isIgnored);
}
public static function routeIgnoredProvider(): Generator
{
yield 'single method matches' => [
'ignoredMethods' => ['GET'],
'routeMethods' => ['GET'],
'expectedIgnored' => true,
];
yield 'one of multiple methods matches' => [
'ignoredMethods' => ['GET', 'POST'],
'routeMethods' => ['GET', 'PUT'],
'expectedIgnored' => true,
];
yield 'no methods match' => [
'ignoredMethods' => ['GET'],
'routeMethods' => ['POST', 'PUT'],
'expectedIgnored' => false,
];
yield 'all methods match' => [
'ignoredMethods' => ['GET', 'POST', 'PUT'],
'routeMethods' => ['GET', 'POST', 'PUT'],
'expectedIgnored' => true,
];
yield 'partial overlap' => [
'ignoredMethods' => ['POST'],
'routeMethods' => ['GET', 'POST'],
'expectedIgnored' => true,
];
}
#[DataProvider('routeRemovalProvider')]
public function test_it_removes_ignored_routes(
array $initialMethods,
array $removeMethods,
array $routeMethods,
bool $expectedIgnored
): void {
// Arrange
$service = resolve(IgnoredRoutesService::class);
$route = $this->createMockRoute('/api/users', $routeMethods);
$service->add(uri: $route->uri(), methods: $initialMethods, reason: 'Test');
// Act
$service->remove(uri: $route->uri(), methods: $removeMethods);
// Assert
$this->assertEquals($expectedIgnored, $service->isIgnored($route));
}
public static function routeRemovalProvider(): Generator
{
yield 'remove some methods' => [
'initialMethods' => ['GET', 'POST', 'PUT'],
'removeMethods' => ['GET', 'POST'],
'routeMethods' => ['PUT'],
'expectedIgnored' => true,
];
yield 'remove all methods' => [
'initialMethods' => ['GET', 'POST'],
'removeMethods' => ['GET', 'POST'],
'routeMethods' => ['GET', 'POST'],
'expectedIgnored' => false,
];
yield 'remove non-existent route' => [
'initialMethods' => ['GET'],
'removeMethods' => ['PATCH'],
'routeMethods' => ['GET'],
'expectedIgnored' => true,
];
}
public function test_it_handles_corrupted_json_file_gracefully(): void
{
// Arrange
$filePath = base_path(self::FILE_PATH);
$directory = dirname($filePath);
if (! File::exists($directory)) {
File::makeDirectory($directory, 0755, true);
}
File::put($filePath, 'invalid json content');
// Act
$service = resolve(IgnoredRoutesService::class);
// Assert
$this->assertFalse($service->hasIgnoredRoutes());
}
public function test_it_skips_empty_array_content(): void
{
// Arrange
$filePath = base_path(self::FILE_PATH);
$directory = dirname($filePath);
if (! File::exists($directory)) {
File::makeDirectory($directory, 0755, true);
}
File::put($filePath, '[]');
$service = resolve(IgnoredRoutesService::class);
// Act
$hasIgnoredRoutes = $service->hasIgnoredRoutes();
// Assert
$this->assertFalse($hasIgnoredRoutes);
}
/*
* Mocks.
*/
private function createMockRoute(string $uri, array $methods): Route
{
$route = $this->createMock(Route::class);
$route->method('uri')->willReturn($uri);
$route->method('methods')->willReturn($methods);
return $route;
}
}

View File

@@ -0,0 +1,295 @@
<?php
namespace Sunchayn\Nimbus\Tests\App\Modules\Routes\Services;
use Illuminate\Contracts\Config\Repository as ConfigRepository;
use Illuminate\Routing\Route;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Route as RouteFacade;
use Mockery\MockInterface;
use PHPUnit\Framework\Attributes\CoversClass;
use RuntimeException;
use Sunchayn\Nimbus\Modules\Routes\Actions\ExtractRoutesAction;
use Sunchayn\Nimbus\Modules\Routes\DataTransferObjects\ExtractedRoute;
use Sunchayn\Nimbus\Modules\Routes\Exceptions\RouteExtractionInternalException;
use Sunchayn\Nimbus\Modules\Routes\Extractor\SchemaExtractor;
use Sunchayn\Nimbus\Modules\Routes\Factories\ExtractableRouteFactory;
use Sunchayn\Nimbus\Modules\Routes\Services\IgnoredRoutesService;
use Sunchayn\Nimbus\Modules\Routes\ValueObjects\ExtractableRoute;
use Sunchayn\Nimbus\Modules\Schemas\ValueObjects\Schema;
use Sunchayn\Nimbus\Modules\Schemas\ValueObjects\SchemaProperty;
use Sunchayn\Nimbus\Tests\TestCase;
#[CoversClass(ExtractRoutesAction::class)]
#[CoversClass(RouteExtractionInternalException::class)]
class RouteExtractorServiceFunctionalTest extends TestCase
{
protected function defineRoutes($router): void
{
$router
->post('/api/users', fn () => response()->json(['users' => []]))
->name('api.users.store');
$router
->get('/api/users/{id}', fn () => response()->json(['user' => []]))
->name('api.users.show');
$router
->post('/api/posts', fn () => response()->json(['posts' => []]))
->name('api.posts.store');
$router
->post('/non-api/posts', fn () => response()->json(['posts' => []]))
->name('non-api.posts.store');
}
public function test_it_processes_routes_properly(): void
{
// Anticipate
$this->mock(ConfigRepository::class, function (MockInterface $mock) {
$mock->shouldReceive('get')->with('nimbus.routes.prefix')->andReturn('api');
$mock->shouldReceive('get')->with('nimbus.routes.versioned')->andReturn(fake()->boolean());
});
$routeFactoryMock = $this->mock(ExtractableRouteFactory::class, function (MockInterface $mock) {
$mock
->shouldReceive('fromLaravelRoute')
->withAnyArgs()
->andReturnUsing(fn (Route $route) => new ExtractableRoute(
parameters: ['test_placeholder:name' => $route->getName()],
codeParser: static fn () => '::fake::',
))
->times(3);
});
$schemaExtractorMock = $this->mock(SchemaExtractor::class, function (MockInterface $mock) {
$mock
->shouldReceive('extract')
->withAnyArgs()
->andReturnUsing(
fn (ExtractableRoute $route) => new Schema([new SchemaProperty('foobar')]),
)
->times(3);
});
// Arrange
$routeExtractorService = resolve(ExtractRoutesAction::class);
$routes = RouteFacade::getRoutes()->getRoutes();
// Act
$result = $routeExtractorService->execute($routes);
// Assert
$this->assertEquals(3, $result->count());
$this->assertContainsOnlyInstancesOf(
ExtractedRoute::class,
$result,
);
$result->each(function (ExtractedRoute $extractedRoute) use ($routeFactoryMock, $schemaExtractorMock, $routes) {
$this->assertTrue(
str_starts_with($extractedRoute->uri->value, 'api'),
"Route should start with api prefix: {$extractedRoute->uri->value}.",
);
$originalRoute = Arr::first(
$routes,
// We can do this simple check because we didn't set up routes that share the same URI.
fn (Route $route) => $route->uri() === $extractedRoute->uri->value,
);
$this->assertEquals(
array_values(
Arr::where(
$originalRoute->methods(),
fn (string $method) => ! in_array($method, ['HEAD']),
),
),
$extractedRoute->methods,
);
$this->assertEquals(
[
'foobar' => [
'type' => 'string',
'x-name' => 'foobar',
'x-required' => false,
],
],
$extractedRoute->schema->toArray(),
);
$this->assertNotNull($originalRoute);
$routeFactoryMock
->shouldHaveReceived(
'fromLaravelRoute',
fn (Route $routeArg) => $routeArg->uri() === $originalRoute->uri && $routeArg->methods() === $originalRoute->methods,
);
$schemaExtractorMock
->shouldHaveReceived(
'extract',
fn (ExtractableRoute $extractableRouteArg) => $extractableRouteArg->parameters['test_placeholder:name'] === $originalRoute->getName()
&& ($extractableRouteArg->codeParser)() === '::fake::',
);
});
}
public function test_process_excludes_ignored_routes(): void
{
// Arrange
$ignoredRoutesServiceMock = $this->mock(IgnoredRoutesService::class)->makePartial();
$routeExtractorService = resolve(ExtractRoutesAction::class);
$routes = RouteFacade::getRoutes()->getRoutes();
// Anticipate
$ignoredRoutesServiceMock->shouldReceive('hasIgnoredRoutes')->andReturnTrue();
$ignoredRoutesServiceMock
->shouldReceive('isIgnored')
->withArgs(fn (Route $route) => $route->uri() === 'api/users' && $route->methods() === ['POST'])
->andReturnTrue();
// Act
$result = $routeExtractorService->execute($routes);
// Assert
$this->assertCount(2, $result);
$ignoredRouteWithinResult = $result->first(
fn (ExtractedRoute $extractedRoute) => $extractedRoute->uri->value === 'api/users'
&& in_array('POST', $extractedRoute->methods),
);
$this->assertNull(
$ignoredRouteWithinResult,
'Ignored route should not be in results',
);
}
public function test_process_handles_empty_routes_array(): void
{
// Arrange
$routeExtractorService = resolve(ExtractRoutesAction::class);
$routes = [];
// Act
$result = $routeExtractorService->execute($routes);
// Assert
$this->assertEquals(0, $result->count());
}
public function test_process_uses_config_prefix(): void
{
// Arrange
$config = $this->mock(ConfigRepository::class);
RouteFacade::post('/custom/test', fn () => response()->json(['test' => true]))
->name('custom.test');
$routeExtractorService = resolve(ExtractRoutesAction::class);
$routes = RouteFacade::getRoutes()->getRoutes();
// Anticipate
$config->shouldReceive('get')->with('nimbus.routes.prefix')->andReturn('custom');
$config->shouldReceive('get')->with('nimbus.routes.versioned')->andReturnFalse();
// Act
$result = $routeExtractorService->execute($routes);
// Assert
$customRouteWithinResult = $result->first(
fn (ExtractedRoute $extractedRoute) => str_starts_with($extractedRoute->uri->value, 'custom'),
);
$this->assertNotNull(
$customRouteWithinResult,
'Should include routes with custom prefix',
);
}
public function test_it_handles_extraction_errors_gracefully(): void
{
// Anticipate
$this->mock(ConfigRepository::class, function (MockInterface $mock) {
$mock->shouldReceive('get')->with('nimbus.routes.prefix')->andReturn('api');
$mock->shouldReceive('get')->with('nimbus.routes.versioned')->andReturn(fake()->boolean());
});
$dummyFailingException = new RuntimeException(message: $dummyFailingExceptionMessage = fake()->sentence());
$this->mock(SchemaExtractor::class, function (MockInterface $mock) use ($dummyFailingException) {
$mock
->shouldReceive('extract')
->withAnyArgs()
->andThrow($dummyFailingException);
});
// Arrange
$routeExtractorService = resolve(ExtractRoutesAction::class);
$routes = RouteFacade::getRoutes()->getRoutes();
// Act
try {
$result = $routeExtractorService->execute($routes);
} catch (RouteExtractionInternalException $exception) {
}
// Assert
$this->assertNotNull($exception);
$this->assertSame(
$dummyFailingException,
$exception->getPrevious(),
);
$this->assertEquals(
[
'uri' => 'api/users', // <- First route from the list self::defineRoutes.
'methods' => ['POST'],
'controllerClass' => '[unspecified]',
'controllerMethod' => '[unspecified]',
],
$exception->getRouteContext()
);
$this->assertEquals(
"Failed to extract route information for 'api/users' due to an unexpected error: {$dummyFailingExceptionMessage}",
$exception->getMessage(),
);
$this->assertEquals(
'Check the application logs for more details and ensure all dependencies are properly installed.'
.'<br />In case of internal errors, please open an issue: <a class="hover:underline" href="https://github.com/sunchayn/nimbus/issues/new/choose">https://github.com/sunchayn/nimbus/issues/new/choose</a>',
$exception->getSuggestedSolution()
);
}
}

View File

@@ -0,0 +1,178 @@
<?php
namespace Sunchayn\Nimbus\Tests\App\Modules\Routes\Services\Uri;
use Generator;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\TestWith;
use PHPUnit\Framework\TestCase;
use Sunchayn\Nimbus\Modules\Routes\Services\Uri\NonVersionedUri;
#[CoversClass(NonVersionedUri::class)]
class NonVersionedUriUnitTest extends TestCase
{
#[TestWith(['/users'])]
#[TestWith(['/v1/users'])]
public function test_it_always_returns_na_for_version(string $value): void
{
$uri = new NonVersionedUri(value: $value, routesPrefix: '');
$this->assertEquals('n/a', $uri->getVersion());
}
#[DataProvider('resourceExtractionProvider')]
public function test_it_extracts_resource_correctly(
string $value,
string $routesPrefix,
string $expectedResource
): void {
$uri = new NonVersionedUri($value, $routesPrefix);
$this->assertEquals($expectedResource, $uri->getResource());
}
public static function resourceExtractionProvider(): Generator
{
yield 'simple uri' => [
'value' => '/users',
'routesPrefix' => '',
'expectedResource' => 'users',
];
yield 'uri with multiple segments' => [
'value' => '/users/123/profile',
'routesPrefix' => '',
'expectedResource' => 'users',
];
yield 'uri with routesPrefix' => [
'value' => '/api/users',
'routesPrefix' => 'api',
'expectedResource' => 'users',
];
yield 'uri with routesPrefix and multiple segments' => [
'value' => '/api/users/123',
'routesPrefix' => 'api',
'expectedResource' => 'users',
];
yield 'routesPrefix not at start' => [
'value' => '/users/api/123',
'routesPrefix' => 'api',
'expectedResource' => 'users', // <- /api/.. is considered part of the URI and disregarded the prefix.
];
yield 'empty uri' => [
'value' => '',
'routesPrefix' => '',
'expectedResource' => '',
];
yield 'only slashes' => [
'value' => '///',
'routesPrefix' => '',
'expectedResource' => '',
];
yield 'only routesPrefix' => [
'value' => '/api',
'routesPrefix' => 'api',
'expectedResource' => '',
];
yield 'only routesPrefix with trailing slash' => [
'value' => '/api/',
'routesPrefix' => 'api',
'expectedResource' => '',
];
yield 'empty routesPrefix' => [
'value' => '/users',
'routesPrefix' => '',
'expectedResource' => 'users',
];
yield 'uri without leading slash' => [
'value' => 'users/123',
'routesPrefix' => '',
'expectedResource' => 'users',
];
yield 'uri with trailing slash' => [
'value' => '/users/',
'routesPrefix' => '',
'expectedResource' => 'users',
];
yield 'multiple consecutive slashes' => [
'value' => '//users//123//',
'routesPrefix' => '',
'expectedResource' => 'users',
];
yield 'routesPrefix without leading slash in uri' => [
'value' => 'api/users',
'routesPrefix' => 'api',
'expectedResource' => 'users',
];
yield 'single character resource' => [
'value' => '/a',
'routesPrefix' => '',
'expectedResource' => 'a',
];
yield 'numeric resource' => [
'value' => '/123',
'routesPrefix' => '',
'expectedResource' => '123',
];
yield 'resource with special characters' => [
'value' => '/user-profile',
'routesPrefix' => '',
'expectedResource' => 'user-profile',
];
yield 'case sensitive routesPrefix mismatch' => [
'value' => '/API/users',
'routesPrefix' => 'api',
'expectedResource' => 'API',
];
yield 'routesPrefix as part of resource name' => [
'value' => '/api/api-users',
'routesPrefix' => 'api',
'expectedResource' => 'api-users',
];
yield 'deeply nested uri' => [
'value' => '/api/v1/users/123/posts/456/comments',
'routesPrefix' => 'api',
'expectedResource' => 'v1', // <- remember: we are testing the `NonVersionedUri`
];
yield 'whitespace in uri' => [
'value' => '/ users /123',
'routesPrefix' => '',
'expectedResource' => ' users ',
];
yield 'url encoded characters' => [
'value' => '/user%20name',
'routesPrefix' => '',
'expectedResource' => 'user%20name',
];
}
public function test_it_can_call_get_resource_multiple_times(): void
{
$uri = new NonVersionedUri('/users', '');
// This asserts against mutating the original value.
$this->assertEquals('users', $uri->getResource());
$this->assertEquals('users', $uri->getResource());
}
}

View File

@@ -0,0 +1,371 @@
<?php
namespace Sunchayn\Nimbus\Tests\App\Modules\Routes\Services\Uri;
use Generator;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\TestWith;
use PHPUnit\Framework\TestCase;
use Sunchayn\Nimbus\Modules\Routes\Services\Uri\VersionedUri;
#[CoversClass(VersionedUri::class)]
class VersionedUriUnitTest extends TestCase
{
#[DataProvider('versionExtractionProvider')]
public function test_it_extracts_version_correctly(
string $value,
string $routesPrefix,
string $expectedVersion
): void {
$uri = new VersionedUri(value: $value, routesPrefix: $routesPrefix);
$this->assertEquals($expectedVersion, $uri->getVersion());
}
public static function versionExtractionProvider(): Generator
{
yield 'simple versioned uri with v prefix' => [
'value' => '/v1/users',
'routesPrefix' => '',
'expectedVersion' => 'v1',
];
yield 'versioned uri with numeric version' => [
'value' => '/v2/posts',
'routesPrefix' => '',
'expectedVersion' => 'v2',
];
yield 'versioned uri with semantic version' => [
'value' => '/1.0/users',
'routesPrefix' => '',
'expectedVersion' => '1.0',
];
yield 'versioned uri with semantic version multiple digits' => [
'value' => '/2.5/users',
'routesPrefix' => '',
'expectedVersion' => '2.5',
];
yield 'versioned uri with prefix' => [
'value' => '/api/v1/users',
'routesPrefix' => 'api',
'expectedVersion' => 'v1',
];
yield 'versioned uri with prefix and semantic version' => [
'value' => '/api/1.0/users',
'routesPrefix' => 'api',
'expectedVersion' => '1.0',
];
yield 'non-versioned uri returns default version' => [
'value' => '/users',
'routesPrefix' => '',
'expectedVersion' => 'v1',
];
yield 'non-versioned uri with prefix returns default version' => [
'value' => '/api/users',
'routesPrefix' => 'api',
'expectedVersion' => 'v1',
];
yield 'uri with invalid version format returns default version' => [
'value' => '/version1/users',
'routesPrefix' => '',
'expectedVersion' => 'v1',
];
yield 'uri with v but no number returns default version' => [
'value' => '/v/users',
'routesPrefix' => '',
'expectedVersion' => 'v1',
];
yield 'uri with v and letters returns default version' => [
'value' => '/vabc/users',
'routesPrefix' => '',
'expectedVersion' => 'v1',
];
yield 'empty uri returns default version' => [
'value' => '',
'routesPrefix' => '',
'expectedVersion' => 'v1',
];
yield 'only slashes returns default version' => [
'value' => '///',
'routesPrefix' => '',
'expectedVersion' => 'v1',
];
yield 'only prefix returns default version' => [
'value' => '/api',
'routesPrefix' => 'api',
'expectedVersion' => 'v1',
];
yield 'only version returns that version' => [
'value' => '/v1',
'routesPrefix' => '',
'expectedVersion' => 'v1',
];
yield 'prefix and only version' => [
'value' => '/api/v1',
'routesPrefix' => 'api',
'expectedVersion' => 'v1',
];
yield 'version without leading slash' => [
'value' => 'v1/users',
'routesPrefix' => '',
'expectedVersion' => 'v1',
];
yield 'multiple consecutive slashes with version' => [
'value' => '//v1//users',
'routesPrefix' => '',
'expectedVersion' => 'v1',
];
yield 'version with trailing slash' => [
'value' => '/v1/',
'routesPrefix' => '',
'expectedVersion' => 'v1',
];
yield 'case sensitive prefix mismatch' => [
'value' => '/API/v1/users', // <- API is treated as resource, not prefix
'routesPrefix' => 'api',
'expectedVersion' => 'v1', // <- Default value.
];
yield 'version-like string in middle is not detected' => [
'value' => '/users/v2/posts', // <- only first part after prefix is checked
'routesPrefix' => '',
'expectedVersion' => 'v1', // <- Default value.
];
yield 'three digit semantic version returns empty' => [
'value' => '/1.0.0/users', // <- regex only matches X.Y format
'routesPrefix' => '',
'expectedVersion' => 'v1', // <- Default value.
];
yield 'v with multiple digits' => [
'value' => '/v123/users',
'routesPrefix' => '',
'expectedVersion' => 'v123',
];
yield 'semantic version with large numbers' => [
'value' => '/99.99/users',
'routesPrefix' => '',
'expectedVersion' => '99.99',
];
}
#[DataProvider('resourceExtractionProvider')]
public function test_it_extracts_resource_correctly(
string $value,
string $routesPrefix,
string $expectedResource
): void {
$uri = new VersionedUri(value: $value, routesPrefix: $routesPrefix);
$this->assertEquals($expectedResource, $uri->getResource());
}
public static function resourceExtractionProvider(): Generator
{
yield 'versioned uri extracts resource after version' => [
'value' => '/v1/users',
'routesPrefix' => '',
'expectedResource' => 'users',
];
yield 'versioned uri with semantic version extracts resource' => [
'value' => '/1.0/users',
'routesPrefix' => '',
'expectedResource' => 'users',
];
yield 'versioned uri with prefix extracts resource' => [
'value' => '/api/v1/users',
'routesPrefix' => 'api',
'expectedResource' => 'users',
];
yield 'versioned uri with multiple segments extracts first resource' => [
'value' => '/v1/users/123/profile',
'routesPrefix' => '',
'expectedResource' => 'users',
];
yield 'non-versioned uri extracts resource' => [
'value' => '/users',
'routesPrefix' => '',
'expectedResource' => 'users',
];
yield 'non-versioned uri with prefix extracts resource' => [
'value' => '/api/users',
'routesPrefix' => 'api',
'expectedResource' => 'users',
];
yield 'non-versioned uri with multiple segments' => [
'value' => '/users/123',
'routesPrefix' => '',
'expectedResource' => 'users',
];
yield 'empty uri returns empty resource' => [
'value' => '',
'routesPrefix' => '',
'expectedResource' => '',
];
yield 'only slashes returns empty resource' => [
'value' => '///',
'routesPrefix' => '',
'expectedResource' => '',
];
yield 'only prefix returns empty resource' => [
'value' => '/api',
'routesPrefix' => 'api',
'expectedResource' => '',
];
yield 'only version returns empty resource' => [
'value' => '/v1',
'routesPrefix' => '',
'expectedResource' => '', // <- no resource after version
];
yield 'prefix and only version returns empty resource' => [
'value' => '/api/v1',
'routesPrefix' => 'api',
'expectedResource' => '',
];
yield 'version without leading slash' => [
'value' => 'v1/users',
'routesPrefix' => '',
'expectedResource' => 'users',
];
yield 'multiple consecutive slashes with version' => [
'value' => '//v1//users',
'routesPrefix' => '',
'expectedResource' => 'users',
];
yield 'resource without leading slash' => [
'value' => 'users/123',
'routesPrefix' => '',
'expectedResource' => 'users',
];
yield 'versioned uri with trailing slash' => [
'value' => '/v1/users/',
'routesPrefix' => '',
'expectedResource' => 'users',
];
yield 'single character resource with version' => [
'value' => '/v1/a',
'routesPrefix' => '',
'expectedResource' => 'a',
];
yield 'numeric resource with version' => [
'value' => '/v1/123',
'routesPrefix' => '',
'expectedResource' => '123',
];
yield 'resource with special characters and version' => [
'value' => '/v1/user-profile',
'routesPrefix' => '',
'expectedResource' => 'user-profile',
];
yield 'case sensitive prefix mismatch' => [
'value' => '/API/v1/users',
'routesPrefix' => 'api',
'expectedResource' => 'API', // <- API is treated as resource
];
yield 'prefix as part of resource name' => [
'value' => '/api/v1/api-users',
'routesPrefix' => 'api',
'expectedResource' => 'api-users',
];
yield 'deeply nested versioned uri' => [
'value' => '/api/v1/users/123/posts/456',
'routesPrefix' => 'api',
'expectedResource' => 'users',
];
yield 'whitespace in resource with version' => [
'value' => '/v1/ users /123',
'routesPrefix' => '',
'expectedResource' => ' users ',
];
yield 'url encoded characters in resource' => [
'value' => '/v1/user%20name',
'routesPrefix' => '',
'expectedResource' => 'user%20name',
];
yield 'invalid version format treats first part as resource' => [
'value' => '/version1/users',
'routesPrefix' => '',
'expectedResource' => 'version1', // <- not a valid version format
];
yield 'three digit semantic version treats it as resource' => [
'value' => '/1.0.0/users',
'routesPrefix' => '',
'expectedResource' => '1.0.0', // <- doesn't match version regex
];
}
#[TestWith(['/v1/users', ''])]
#[TestWith(['/api/v2/posts', 'api'])]
#[TestWith(['/1.0/resources', ''])]
public function test_it_can_call_get_version_multiple_times(string $value, string $routesPrefix): void
{
$uri = new VersionedUri(value: $value, routesPrefix: $routesPrefix);
// This asserts against mutating the original value.
$firstCall = $uri->getVersion();
$secondCall = $uri->getVersion();
$this->assertEquals($firstCall, $secondCall);
$this->assertNotEmpty($firstCall);
}
#[TestWith(['/v1/users'])]
#[TestWith(['/api/v2/posts'])]
#[TestWith(['/users'])]
public function test_it_can_call_get_resource_multiple_times(string $value): void
{
$uri = new VersionedUri(value: $value, routesPrefix: '');
// This asserts against mutating the original value.
$firstCall = $uri->getResource();
$secondCall = $uri->getResource();
$this->assertEquals($firstCall, $secondCall);
}
}

View File

@@ -0,0 +1,283 @@
<?php
namespace Sunchayn\Nimbus\Tests\App\Modules\Routes\ValueObjects;
use Generator;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use RuntimeException;
use Sunchayn\Nimbus\Modules\Routes\ValueObjects\Endpoint;
#[CoversClass(Endpoint::class)]
class EndpointUnitTest extends TestCase
{
#[DataProvider('versionedEndpointProvider')]
public function test_it_creates_versioned_endpoint_from_raw(
string $uri,
string $routesPrefix,
string $expectedVersion,
string $expectedResource,
): void {
// Act
$endpoint = Endpoint::fromRaw(
uri: $uri,
routesPrefix: $routesPrefix,
isVersioned: true
);
// Assert
$this->assertEquals($expectedVersion, $endpoint->version);
$this->assertEquals($expectedResource, $endpoint->resource);
$this->assertEquals($uri, $endpoint->value);
}
public static function versionedEndpointProvider(): Generator
{
yield 'simple uri' => [
'uri' => '/v1/users',
'routesPrefix' => '',
'expectedVersion' => 'v1',
'expectedResource' => 'users',
];
yield 'uri with semantic version' => [
'uri' => '/1.0/posts',
'routesPrefix' => '',
'expectedVersion' => '1.0',
'expectedResource' => 'posts',
];
yield 'uri with prefix' => [
'uri' => '/api/v1/users',
'routesPrefix' => 'api',
'expectedVersion' => 'v1',
'expectedResource' => 'users',
];
yield 'uri with prefix and semantic version' => [
'uri' => '/rest-api/2.0/products',
'routesPrefix' => 'rest-api',
'expectedVersion' => '2.0',
'expectedResource' => 'products',
];
yield 'uri with multiple segments' => [
'uri' => '/v1/users/{user}/posts',
'routesPrefix' => '',
'expectedVersion' => 'v1',
'expectedResource' => 'users',
];
yield 'uri with prefix and path parameters' => [
'uri' => '/api/v2/users/{id}',
'routesPrefix' => 'api',
'expectedVersion' => 'v2',
'expectedResource' => 'users',
];
yield 'empty uri' => [
'uri' => '',
'routesPrefix' => '',
'expectedVersion' => 'v1', // <- Default version.
'expectedResource' => '',
];
}
#[DataProvider('nonVersionedEndpointProvider')]
public function test_it_creates_non_versioned_endpoint_from_raw(
string $uri,
string $routesPrefix,
string $expectedVersion,
string $expectedResource,
): void {
// Act
$endpoint = Endpoint::fromRaw(
uri: $uri,
routesPrefix: $routesPrefix,
isVersioned: false
);
// Assert
$this->assertEquals($expectedVersion, $endpoint->version);
$this->assertEquals($expectedResource, $endpoint->resource);
$this->assertEquals($uri, $endpoint->value);
}
public static function nonVersionedEndpointProvider(): Generator
{
yield 'simple uri' => [
'uri' => '/users',
'routesPrefix' => '',
'expectedVersion' => 'n/a',
'expectedResource' => 'users',
];
yield 'uri with prefix' => [
'uri' => '/api/users',
'routesPrefix' => 'api',
'expectedVersion' => 'n/a',
'expectedResource' => 'users',
];
yield 'uri with multiple segments' => [
'uri' => '/users/{user}/posts',
'routesPrefix' => '',
'expectedVersion' => 'n/a',
'expectedResource' => 'users',
];
yield 'uri with prefix and path parameters' => [
'uri' => '/api/posts/{id}',
'routesPrefix' => 'api',
'expectedVersion' => 'n/a',
'expectedResource' => 'posts',
];
yield 'empty uri' => [
'uri' => '',
'routesPrefix' => '',
'expectedVersion' => 'n/a',
'expectedResource' => '',
];
}
#[DataProvider('shortUriProvider')]
public function test_it_returns_short_uri_correctly(
string $version,
string $resource,
string $value,
string $expectedShortUri,
): void {
// Arrange
$endpoint = new Endpoint(
version: $version,
resource: $resource,
value: $value,
);
// Act & Assert
$this->assertEquals($expectedShortUri, $endpoint->getShortUri());
}
public static function shortUriProvider(): Generator
{
yield 'versioned uri with prefix removes prefix and version' => [
'version' => 'v1',
'resource' => 'users',
'value' => '/rest-api/v1/users/{user}',
'expectedShortUri' => '/users/{user}',
];
yield 'versioned uri without prefix removes only version' => [
'version' => 'v1',
'resource' => 'users',
'value' => '/v1/users/{user}',
'expectedShortUri' => '/users/{user}',
];
yield 'uri with prefix removes only prefix' => [
'version' => 'n/a',
'resource' => 'users',
'value' => '/api/users/{user}',
'expectedShortUri' => '/users/{user}',
];
yield 'uri without prefix returns as is' => [
'version' => 'n/a',
'resource' => 'users',
'value' => '/users/{user}',
'expectedShortUri' => '/users/{user}',
];
yield 'simple resource without parameters' => [
'version' => 'v1',
'resource' => 'users',
'value' => '/api/v1/users',
'expectedShortUri' => '/users',
];
yield 'deeply nested uri' => [
'version' => 'v1',
'resource' => 'users',
'value' => '/api/v1/users/{user}/posts/{post}/comments',
'expectedShortUri' => '/users/{user}/posts/{post}/comments',
];
yield 'uri with semantic version' => [
'version' => '1.0',
'resource' => 'products',
'value' => '/api/1.0/products/{id}',
'expectedShortUri' => '/products/{id}',
];
}
#[DataProvider('invalidShortUriProvider')]
public function test_it_throws_exception_for_invalid_short_uri(
string $version,
string $resource,
string $value,
string $expectedMessage,
): void {
// Arrange
$endpoint = new Endpoint(
version: $version,
resource: $resource,
value: $value,
);
// Anticipate
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage($expectedMessage);
// Act
$endpoint->getShortUri();
}
public static function invalidShortUriProvider(): Generator
{
yield 'empty resource' => [
'version' => 'v1',
'resource' => '',
'value' => '/v1/users',
'expectedMessage' => 'Invalid ValueObject. The resource cannot be empty.',
];
yield 'resource not in uri' => [
'version' => 'v1',
'resource' => 'posts',
'value' => '/v1/users/{user}',
'expectedMessage' => 'Invalid ValueObject. The `resource` MUST exist in the URI.',
];
yield 'resource with different casing' => [
'version' => 'v1',
'resource' => 'Users',
'value' => '/v1/users',
'expectedMessage' => 'Invalid ValueObject. The `resource` MUST exist in the URI.',
];
yield 'resource without leading slash in uri' => [
'version' => 'n/a',
'resource' => 'users',
'value' => 'users/{user}',
'expectedMessage' => 'Invalid ValueObject. The `resource` MUST exist in the URI.',
];
yield 'resource appears later in path' => [
'version' => 'v1',
'resource' => 'comments',
'value' => '/v1/users/{user}/posts',
'expectedMessage' => 'Invalid ValueObject. The `resource` MUST exist in the URI.',
];
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace Sunchayn\Nimbus\Tests\App\Modules\Routes\ValueObjects;
use Mockery;
use PHPUnit\Framework\Attributes\CoversClass;
use Sunchayn\Nimbus\Modules\Routes\ValueObjects\ExtractableRoute;
use Sunchayn\Nimbus\Tests\TestCase;
#[CoversClass(ExtractableRoute::class)]
class ExtractableRouteUnitTest extends TestCase
{
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
public function test_it_creates_empty_objects(): void
{
// Act
$actual = ExtractableRoute::empty();
// Assert
$this->assertEmpty($actual->parameters);
$this->assertEmpty(($actual->codeParser)());
$this->assertNull($actual->methodName);
$this->assertNull($actual->controllerClass);
$this->assertNull($actual->controllerMethod);
}
}

View File

@@ -0,0 +1,474 @@
<?php
namespace Sunchayn\Nimbus\Tests\App\Modules\Schemas\Builders;
namespace Sunchayn\Nimbus\Tests\App\Modules\Schemas\Builders;
use Generator;
use Illuminate\Support\Arr;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Enum;
use Illuminate\Validation\Rules\In;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use stdClass;
use Sunchayn\Nimbus\Modules\Schemas\Builders\PropertyBuilder;
use Sunchayn\Nimbus\Modules\Schemas\Builders\SchemaBuilder;
use Sunchayn\Nimbus\Modules\Schemas\Collections\Ruleset;
use Sunchayn\Nimbus\Modules\Schemas\RulesMapper\Processors\EnumRuleProcessor;
use Sunchayn\Nimbus\Modules\Schemas\RulesMapper\Processors\InRuleProcessor;
use Sunchayn\Nimbus\Modules\Schemas\RulesMapper\RuleToSchemaMapper;
use Sunchayn\Nimbus\Modules\Schemas\ValueObjects\FieldPath;
use Sunchayn\Nimbus\Modules\Schemas\ValueObjects\Schema;
use Sunchayn\Nimbus\Modules\Schemas\ValueObjects\SchemaProperty;
use Sunchayn\Nimbus\Tests\App\Modules\Schemas\Builders\Stubs\StatusEnumStub;
#[CoversClass(SchemaBuilder::class)]
#[CoversClass(PropertyBuilder::class)]
#[CoversClass(FieldPath::class)]
#[CoversClass(RuleToSchemaMapper::class)]
#[CoversClass(InRuleProcessor::class)]
#[CoversClass(EnumRuleProcessor::class)]
// TODO [Test] Move the mapper and process to their own tests.
class SchemaBuilderUnitTest extends TestCase
{
private SchemaBuilder $schemaBuilder;
protected function setUp(): void
{
parent::setUp();
$this->schemaBuilder = $this->createSchemaBuilder();
}
#[DataProvider('schemaBuilderDataProvider')]
public function test_builds_schema_with_correct_properties(
array $rules,
Schema $expectedSchema,
): void {
// Act
$actual = $this->schemaBuilder->buildSchemaFromRuleset(Ruleset::fromLaravelRules($rules));
// Assert
$this->assertEquals(
$expectedSchema,
$actual,
);
}
public static function schemaBuilderDataProvider(): Generator
{
yield 'simple rules' => [
'rules' => [
'name' => 'required|string',
'email' => 'required|email',
'age' => 'required_with:email|integer|min:18|max:99',
'statuses' => ['required', new Enum(StatusEnumStub::class)],
'statuses_v2' => ['required', Rule::enum(StatusEnumStub::class)],
'role' => ['required', new In(1, 2, 3, 4)],
'role_v2' => ['required', Rule::in(1, 2, 3, 4)],
'role_2' => ['required', new In(new stdClass, 2, 3)], // <- Assert we rebase the array after filtering.
],
'expectedSchema' => new Schema(
properties: [
new SchemaProperty(name: 'name', type: 'string', required: true, format: null, itemsSchema: null, propertiesSchema: null),
new SchemaProperty(name: 'email', type: 'string', required: true, format: 'email', itemsSchema: null, propertiesSchema: null),
new SchemaProperty(name: 'age', type: 'integer', required: false, format: null, itemsSchema: null, propertiesSchema: null, minimum: 18, maximum: 99),
new SchemaProperty(name: 'statuses', type: 'string', required: true, format: null, enum: ['inactive', 'active'], itemsSchema: null, propertiesSchema: null),
new SchemaProperty(name: 'statuses_v2', type: 'string', required: true, format: null, enum: ['inactive', 'active'], itemsSchema: null, propertiesSchema: null),
new SchemaProperty(name: 'role', type: 'integer', required: true, format: null, enum: [1, 2, 3, 4], itemsSchema: null, propertiesSchema: null),
new SchemaProperty(name: 'role_v2', type: 'integer', required: true, format: null, enum: [1, 2, 3, 4], itemsSchema: null, propertiesSchema: null),
new SchemaProperty(name: 'role_2', type: 'integer', required: true, format: null, enum: [2, 3], itemsSchema: null, propertiesSchema: null),
],
extractionError: null,
),
];
yield 'nested object rules' => [
'rules' => [
'user.name' => 'required|string',
'user.email' => 'required|email',
],
'expectedSchema' => new Schema(
properties: [
new SchemaProperty(
name: 'user',
type: 'object',
required: false,
format: null,
itemsSchema: null,
propertiesSchema: new Schema(
properties: [
new SchemaProperty(name: 'name', type: 'string', required: true, format: null, itemsSchema: null, propertiesSchema: null),
new SchemaProperty(name: 'email', type: 'string', required: true, format: 'email', itemsSchema: null, propertiesSchema: null),
],
extractionError: null,
),
),
],
extractionError: null,
),
];
yield 'array of primitives' => [
'rules' => [
'tags' => 'array',
'tags.*' => 'string',
],
'expectedSchema' => new Schema(
properties: [
new SchemaProperty(
name: 'tags',
type: 'array',
required: false,
format: null,
itemsSchema: new SchemaProperty(
name: 'tag', // <- Singular value of parent property `tags`.
type: 'string',
required: false,
format: null,
itemsSchema: null,
propertiesSchema: null,
),
propertiesSchema: null,
),
],
extractionError: null,
),
];
yield 'array of objects rules' => [
'rules' => [
'persons' => 'array|required',
'persons.*.email' => 'string|email',
'persons.*.username' => 'string|max:20',
],
'expectedSchema' => new Schema(
properties: [
new SchemaProperty(
name: 'persons',
type: 'array',
required: true,
format: null,
itemsSchema: new SchemaProperty(
name: 'item',
type: 'object',
required: false,
format: null,
itemsSchema: null,
propertiesSchema: new Schema(
properties: [
new SchemaProperty(
name: 'email',
type: 'string',
required: false,
format: 'email',
itemsSchema: null,
propertiesSchema: null,
),
new SchemaProperty(
name: 'username',
type: 'string',
required: false,
format: null,
itemsSchema: null,
propertiesSchema: null,
maximum: 20,
),
],
extractionError: null,
)
),
propertiesSchema: null,
),
],
extractionError: null,
),
];
yield 'mixed data types' => [
'rules' => [
'id' => 'required|uuid',
'name' => 'required|string',
'age' => 'integer',
'is_active' => 'boolean',
'salary' => 'numeric',
],
'expectedSchema' => new Schema(
properties: [
new SchemaProperty(name: 'id', type: 'string', required: true, format: 'uuid', itemsSchema: null, propertiesSchema: null),
new SchemaProperty(name: 'name', type: 'string', required: true, format: null, itemsSchema: null, propertiesSchema: null),
new SchemaProperty(name: 'age', type: 'integer', required: false, format: null, itemsSchema: null, propertiesSchema: null),
new SchemaProperty(name: 'is_active', type: 'boolean', required: false, format: null, itemsSchema: null, propertiesSchema: null),
new SchemaProperty(name: 'salary', type: 'number', required: false, format: null, itemsSchema: null, propertiesSchema: null),
],
extractionError: null,
),
];
yield 'empty rules' => [
'rules' => [],
'expectedSchema' => new Schema(
properties: [],
extractionError: null,
),
];
yield 'deep nesting (object)' => [
'rules' => [
'company.department.team.member.name' => 'required|string',
],
'expectedSchema' => new Schema(
properties: [
new SchemaProperty(
name: 'company',
type: 'object',
required: false,
format: null,
itemsSchema: null,
propertiesSchema: new Schema(
properties: [
new SchemaProperty(
name: 'department',
type: 'object',
required: false,
format: null,
itemsSchema: null,
propertiesSchema: new Schema(
properties: [
new SchemaProperty(
name: 'team',
type: 'object',
required: false,
format: null,
itemsSchema: null,
propertiesSchema: new Schema(
properties: [
new SchemaProperty(
name: 'member',
type: 'object',
required: false,
format: null,
itemsSchema: null,
propertiesSchema: new Schema(
properties: [
new SchemaProperty(name: 'name', type: 'string', required: true, format: null, itemsSchema: null, propertiesSchema: null),
],
extractionError: null,
),
),
],
extractionError: null,
),
),
],
extractionError: null,
),
),
],
extractionError: null,
),
),
],
extractionError: null,
),
];
yield 'deep nesting (array)' => [
'rules' => [
'company.teams.*.members.*.member.name' => 'string',
'company.teams.*.members' => 'required|array',
],
'expectedSchema' => new Schema(
properties: [
new SchemaProperty(
name: 'company',
type: 'object',
required: false,
format: null,
enum: null,
itemsSchema: null,
propertiesSchema: new Schema(
properties: [
new SchemaProperty(
name: 'teams',
type: 'array',
required: false,
format: null,
enum: null,
itemsSchema: new SchemaProperty(
name: 'item',
type: 'object',
required: false,
format: null,
enum: null,
itemsSchema: null,
propertiesSchema: new Schema(
properties: [
new SchemaProperty(
name: 'members',
type: 'array',
required: true,
format: null,
enum: null,
itemsSchema: new SchemaProperty(
name: 'item',
type: 'object',
required: false,
format: null,
enum: null,
itemsSchema: null,
propertiesSchema: new Schema(
properties: [
new SchemaProperty(
name: 'member',
type: 'object',
required: false,
format: null,
enum: null,
itemsSchema: null,
propertiesSchema: new Schema(
properties: [
new SchemaProperty(
name: 'name',
type: 'string',
required: false,
format: null,
enum: null,
itemsSchema: null,
propertiesSchema: null,
),
],
extractionError: null,
),
),
],
extractionError: null,
),
),
propertiesSchema: null,
),
],
extractionError: null,
),
),
propertiesSchema: null,
),
],
extractionError: null,
),
),
],
extractionError: null,
),
];
}
#[DataProvider('formatDetectionDataProvider')]
public function test_detects_formats_correctly(
array $rules,
string $propertyName,
?string $expectedFormat
): void {
// Act
$schema = $this->schemaBuilder->buildSchemaFromRuleset(Ruleset::fromLaravelRules($rules));
// Assert
$property = $this->findPropertyByName($schema, $propertyName);
$this->assertNotNull($property, "Property '{$propertyName}' not found");
$this->assertEquals($expectedFormat, $property->format);
}
public static function formatDetectionDataProvider(): Generator
{
yield 'email format' => [
'rules' => ['email' => 'required|email'],
'propertyName' => 'email',
'expectedFormat' => 'email',
];
yield 'UUID format' => [
'rules' => ['id' => 'required|uuid'],
'propertyName' => 'id',
'expectedFormat' => 'uuid',
];
yield 'date-time format' => [
'rules' => ['created_at' => 'required|date'],
'propertyName' => 'created_at',
'expectedFormat' => 'date-time',
];
yield 'no format' => [
'rules' => ['name' => 'required|string'],
'propertyName' => 'name',
'expectedFormat' => null,
];
}
#[DataProvider('enumValuesDataProvider')]
public function test_extracts_enum_values_correctly(
array $rules,
string $propertyName,
?array $expectedEnum
): void {
// Act
$schema = $this->schemaBuilder->buildSchemaFromRuleset(Ruleset::fromLaravelRules($rules));
// Assert
$property = $this->findPropertyByName($schema, $propertyName);
$this->assertNotNull($property, "Property '{$propertyName}' not found");
$this->assertEquals($expectedEnum, $property->enum);
}
public static function enumValuesDataProvider(): Generator
{
yield 'enum values' => [
'rules' => ['status' => 'required|in:active,inactive,pending'],
'propertyName' => 'status',
'expectedEnum' => ['active', 'inactive', 'pending'],
];
yield 'no enum' => [
'rules' => ['name' => 'required|string'],
'propertyName' => 'name',
'expectedEnum' => null,
];
}
/*
* Helpers.
*/
private function findPropertyByName(Schema $schema, string $name): ?SchemaProperty
{
return Arr::first(
$schema->properties,
fn (SchemaProperty $property) => $property->name === $name,
);
}
/*
* Generators.
*/
private function createSchemaBuilder(): SchemaBuilder
{
$ruleMapper = new RuleToSchemaMapper;
$propertyBuilder = new PropertyBuilder($ruleMapper);
return new SchemaBuilder(
$propertyBuilder,
);
}
}

View File

@@ -0,0 +1,10 @@
<?php
namespace Sunchayn\Nimbus\Tests\App\Modules\Schemas\Builders\Stubs;
enum StatusEnumStub: string
{
case INACTIVE = 'inactive';
case ACTIVE = 'active';
}

View File

@@ -0,0 +1,25 @@
<?php
namespace Sunchayn\Nimbus\Tests\App\Modules\Schemas\ValueObjects;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use Sunchayn\Nimbus\Modules\Schemas\ValueObjects\PathSegment;
#[CoversClass(PathSegment::class)]
class PathSegmentUnitTest extends TestCase
{
public function test_it_identifies_array_segments(): void
{
$segment = new PathSegment('*');
$this->assertTrue($segment->isArray());
}
public function test_it_returns_false_for_non_array_segments(): void
{
$segment = new PathSegment('user');
$this->assertFalse($segment->isArray());
}
}

View File

@@ -0,0 +1,118 @@
<?php
namespace Sunchayn\Nimbus\Tests\App\Modules\Schemas\ValueObjects;
use Generator;
use Illuminate\Validation\Rule;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Sunchayn\Nimbus\Modules\Schemas\Collections\Ruleset;
#[CoversClass(Ruleset::class)]
class RulesetUnitTest extends TestCase
{
#[DataProvider('rulesetCreationDataProvider')]
public function test_creates_ruleset_from_various_formats(array $input, array $expected): void
{
// Act
$ruleset = Ruleset::fromLaravelRules($input);
// Assert
$this->assertEquals($expected, $ruleset->all());
}
public static function rulesetCreationDataProvider(): Generator
{
yield 'pipe-separated rules' => [
'input' => ['example' => 'required|string|max:255'],
'expected' => ['example' => ['required', 'string', 'max:255']],
];
yield 'single rule' => [
'input' => ['example' => 'required'],
'expected' => ['example' => ['required']],
];
yield 'empty string' => [
'input' => ['example' => ''],
'expected' => ['example' => []],
];
yield 'array of rules' => [
'input' => ['example' => ['required', 'string', 'max:255']],
'expected' => ['example' => ['required', 'string', 'max:255']],
];
yield 'empty array' => [
'input' => [],
'expected' => [],
];
yield 'null input' => [
'input' => ['example' => null],
'expected' => ['example' => []],
];
yield 'integer input' => [
'input' => ['example' => 123],
'expected' => ['example' => []],
];
yield 'object input' => [
'input' => ['example' => Rule::in(1, 2, 3)],
'expected' => ['example' => [Rule::in(1, 2, 3)]],
];
yield 'trailing pipe' => [
'input' => ['example' => 'required|string|'],
'expected' => ['example' => ['required', 'string']],
];
yield 'leading pipe' => [
'input' => ['example' => '|required|string'],
'expected' => ['example' => ['required', 'string']],
];
yield 'multiple consecutive pipes' => [
'input' => ['example' => 'required||string'],
'expected' => ['example' => ['required', 'string']],
];
}
#[DataProvider('emptyCheckDataProvider')]
public function test_checks_if_ruleset_is_empty(array $rules, bool $expectedEmpty): void
{
// Arrange
$ruleset = new Ruleset($rules);
// Act
$isEmpty = $ruleset->isEmpty();
// Assert
$this->assertEquals($expectedEmpty, $isEmpty);
}
public static function emptyCheckDataProvider(): Generator
{
yield 'Empty ruleset' => [
'rules' => [],
'expectedEmpty' => true,
];
yield 'Non-empty ruleset' => [
'rules' => ['example' => ['required', 'string']],
'expectedEmpty' => false,
];
yield 'Single rule' => [
'rules' => ['example' => ['required']],
'expectedEmpty' => false,
];
}
}

View File

@@ -0,0 +1,283 @@
<?php
namespace Sunchayn\Nimbus\Tests\App\Modules\Schemas\ValueObjects;
use Closure;
use Generator;
use Mockery;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Sunchayn\Nimbus\Modules\Schemas\ValueObjects\Schema;
use Sunchayn\Nimbus\Modules\Schemas\ValueObjects\SchemaProperty;
#[CoversClass(SchemaProperty::class)]
class SchemaPropertyUnitTest extends TestCase
{
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
#[DataProvider('toArrayDataProvider')]
public function test_to_array(SchemaProperty|Closure $property, array $expected): void
{
// Arrange
// Some properties have mock, so we need to evaluate the constructor here and not in the data provider.
// This will prevent flakiness as the data providers are called first (in random order).
// If all mocks are called, then the test will fail as it won't satisfy all expectations.
$property = value($property);
// Act
$result = $property->toArray();
// Assert
$this->assertEquals($expected, $result);
}
public static function toArrayDataProvider(): Generator
{
yield 'a minimal structure' => [
'property' => new SchemaProperty(
name: 'simpleProperty',
type: 'string'
),
'expected' => [
'type' => 'string',
'x-required' => false,
'x-name' => 'simpleProperty',
],
];
yield 'a formatted string' => [
'property' => new SchemaProperty(
name: 'emailProperty',
type: 'string',
format: 'email'
),
'expected' => [
'type' => 'string',
'format' => 'email',
'x-required' => false,
'x-name' => 'emailProperty',
],
];
yield 'an enum' => [
'property' => new SchemaProperty(
name: 'statusProperty',
type: 'string',
enum: ['active', 'inactive', 'pending']
),
'expected' => [
'type' => 'string',
'enum' => ['active', 'inactive', 'pending'],
'x-required' => false,
'x-name' => 'statusProperty',
],
];
yield 'an array of primitives' => [
'property' => fn () => new SchemaProperty(
name: 'arrayProperty',
type: 'array',
itemsSchema: new SchemaProperty(
name: 'item',
type: 'integer',
),
),
'expected' => [
'type' => 'array',
'items' => [
'type' => 'integer',
'x-required' => false,
'x-name' => 'item',
],
'x-required' => false,
'x-name' => 'arrayProperty',
],
];
yield 'an array of objects' => [
'property' => fn () => new SchemaProperty(
name: 'arrayProperty',
type: 'array',
itemsSchema: new SchemaProperty(
name: 'item',
type: 'object',
propertiesSchema: self::getSchemaMock(
[
new SchemaProperty(
name: 'productId',
required: true,
),
],
required: ['productId'],
),
)
),
'expected' => [
'type' => 'array',
'items' => [
'type' => 'object',
'properties' => [
'productId' => [
'type' => 'string',
'x-required' => true,
'x-name' => 'productId',
],
],
'required' => ['productId'],
'x-required' => false,
'x-name' => 'item',
],
'x-required' => false,
'x-name' => 'arrayProperty',
],
];
yield 'an object' => [
'property' => fn () => new SchemaProperty(
name: 'addressProperty',
type: 'object',
propertiesSchema: self::getSchemaMock(
properties: [
new SchemaProperty(
name: 'street',
type: 'string',
required: true,
),
new SchemaProperty(
name: 'city',
type: 'string',
),
],
required: [
'street',
]
),
),
'expected' => [
'type' => 'object',
'properties' => [
'street' => [
'type' => 'string',
'x-required' => true,
'x-name' => 'street',
],
'city' => [
'type' => 'string',
'x-required' => false,
'x-name' => 'city',
],
],
'required' => ['street'],
'x-required' => false,
'x-name' => 'addressProperty',
],
];
yield 'an object without required fields' => [
'property' => fn () => new SchemaProperty(
name: 'addressProperty',
type: 'object',
required: true,
propertiesSchema: self::getSchemaMock(
properties: [
new SchemaProperty(
name: 'street',
type: 'string',
),
new SchemaProperty(
name: 'city',
type: 'string',
),
],
required: []
),
),
'expected' => [
'type' => 'object',
'properties' => [
'street' => [
'type' => 'string',
'x-required' => false,
'x-name' => 'street',
],
'city' => [
'type' => 'string',
'x-required' => false,
'x-name' => 'city',
],
],
'required' => [],
'x-required' => true,
'x-name' => 'addressProperty',
],
];
yield 'a full structure' => [
'property' => new SchemaProperty(
name: 'fullProperty',
type: 'string',
required: true,
format: 'date-time',
enum: ['val1', 'val2'],
),
'expected' => [
'type' => 'string',
'format' => 'date-time',
'enum' => ['val1', 'val2'],
'x-required' => true,
'x-name' => 'fullProperty',
],
];
yield 'an integer with format' => [
'property' => new SchemaProperty(
name: 'ageProperty',
type: 'integer',
format: 'int32',
),
'expected' => [
'type' => 'integer',
'format' => 'int32',
'x-required' => false,
'x-name' => 'ageProperty',
],
];
yield 'a boolean' => [
'property' => new SchemaProperty(
name: 'isActiveProperty',
type: 'boolean',
),
'expected' => [
'type' => 'boolean',
'x-required' => false,
'x-name' => 'isActiveProperty',
],
];
}
/*
* Mocks.
*/
private static function getSchemaMock(array $properties, array $required): Schema
{
/** @var Schema&Mockery\MockInterface $schemaMock */
$schemaMock = Mockery::mock(Schema::class)->makePartial();
$schemaMock->__construct($properties);
$schemaMock->shouldReceive('getRequiredProperties')->andReturn($required);
return $schemaMock;
}
}

View File

@@ -0,0 +1,348 @@
<?php
namespace Sunchayn\Nimbus\Tests\App\Modules\Schemas\ValueObjects;
use Generator;
use Mockery;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use RuntimeException;
use Sunchayn\Nimbus\Modules\Routes\ValueObjects\RulesExtractionError;
use Sunchayn\Nimbus\Modules\Schemas\ValueObjects\Schema;
use Sunchayn\Nimbus\Modules\Schemas\ValueObjects\SchemaProperty;
#[CoversClass(Schema::class)]
class SchemaUnitTest extends TestCase
{
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
public function test_builds_empty_schema(): void
{
// Act
$schema = Schema::empty();
// Assert
$this->assertEmpty($schema->properties);
$this->assertNull($schema->extractionError);
}
#[DataProvider('isEmptyCheckDataProvider')]
public function test_checks_if_empty(Schema $schema, bool $expected): void
{
// Act
$isEmpty = $schema->isEmpty();
// Assert
$this->assertEquals($expected, $isEmpty);
}
public static function isEmptyCheckDataProvider(): Generator
{
yield 'Empty schema' => [
'schema' => new Schema(properties: []),
'expected' => true,
];
yield 'Empty with extraction error' => [
'schema' => new Schema(
properties: [],
extractionError: new RulesExtractionError(new RuntimeException)
),
'expected' => true,
];
yield 'Non-empty schema' => [
'schema' => new Schema(properties: [new SchemaProperty(name: 'field')]),
'expected' => false,
];
}
#[DataProvider('toArrayDataProvider')]
public function test_converts_to_array(array $properties, array $expected): void
{
// Arrange
$schema = new Schema(properties: $properties);
// Act
$result = $schema->toArray();
// Assert
$this->assertEquals($expected, $result);
}
public static function toArrayDataProvider(): Generator
{
yield 'empty schema' => [
'properties' => [],
'expected' => [],
];
yield 'single property' => [
'properties' => [
new class(name: 'name') extends SchemaProperty
{
public function toArray(): array
{
return ['type' => 'string'];
}
},
],
'expected' => [
'name' => ['type' => 'string'],
],
];
yield 'multiple properties' => [
'properties' => [
new class(name: 'name') extends SchemaProperty
{
public function toArray(): array
{
return ['type' => 'string'];
}
},
new class(name: 'age') extends SchemaProperty
{
public function toArray(): array
{
return ['type' => 'integer'];
}
},
],
'expected' => [
'name' => ['type' => 'string'],
'age' => ['type' => 'integer'],
],
];
yield 'properties with formats' => [
'properties' => [
new class(name: 'email') extends SchemaProperty
{
public function toArray(): array
{
return ['type' => 'string', 'format' => 'email'];
}
},
new class(name: 'uuid') extends SchemaProperty
{
public function toArray(): array
{
return ['type' => 'string', 'format' => 'uuid'];
}
},
],
'expected' => [
'email' => ['type' => 'string', 'format' => 'email'],
'uuid' => ['type' => 'string', 'format' => 'uuid'],
],
];
yield 'nested object properties' => [
'properties' => [
new class(name: 'user') extends SchemaProperty
{
public function toArray(): array
{
return [
'type' => 'object',
'properties' => [
'name' => ['type' => 'string'],
'email' => ['type' => 'string', 'format' => 'email'],
],
];
}
},
],
'expected' => [
'user' => [
'type' => 'object',
'properties' => [
'name' => ['type' => 'string'],
'email' => ['type' => 'string', 'format' => 'email'],
],
],
],
];
yield 'array properties' => [
'properties' => [
new class(name: 'tags') extends SchemaProperty
{
public function toArray(): array
{
return [
'type' => 'array',
'items' => ['type' => 'string'],
];
}
},
],
'expected' => [
'tags' => [
'type' => 'array',
'items' => ['type' => 'string'],
],
],
];
}
#[DataProvider('toJsonSchemaDataProvider')]
public function test_converts_to_json_schema(array $properties, array $expected): void
{
// Arrange
$schema = new Schema(properties: $properties);
// Act
$result = $schema->toJsonSchema();
// Assert
$this->assertEquals($expected, $result);
}
public static function toJsonSchemaDataProvider(): Generator
{
yield 'empty schema' => [
'properties' => [],
'expected' => [
'$schema' => 'https://json-schema.org/draft/2020-12/schema',
'type' => 'object',
'properties' => [],
'required' => [],
'additionalProperties' => false,
],
];
yield 'single non-required property' => [
'properties' => [
new class(name: 'name') extends SchemaProperty
{
public function toArray(): array
{
return ['type' => 'string'];
}
},
],
'expected' => [
'$schema' => 'https://json-schema.org/draft/2020-12/schema',
'type' => 'object',
'properties' => [
'name' => ['type' => 'string'],
],
'required' => [],
'additionalProperties' => false,
],
];
yield 'single required property' => [
'properties' => [
new class(name: 'email', required: true) extends SchemaProperty
{
public function toArray(): array
{
return ['type' => 'string', 'format' => 'email'];
}
},
],
'expected' => [
'$schema' => 'https://json-schema.org/draft/2020-12/schema',
'type' => 'object',
'properties' => [
'email' => ['type' => 'string', 'format' => 'email'],
],
'required' => ['email'],
'additionalProperties' => false,
],
];
yield 'mixed required and optional properties' => [
'properties' => [
new class(name: 'name', required: true) extends SchemaProperty
{
public function toArray(): array
{
return ['type' => 'string'];
}
},
new class(name: 'age') extends SchemaProperty
{
public function toArray(): array
{
return ['type' => 'integer'];
}
},
new class(name: 'email', required: true) extends SchemaProperty
{
public function toArray(): array
{
return ['type' => 'string', 'format' => 'email'];
}
},
],
'expected' => [
'$schema' => 'https://json-schema.org/draft/2020-12/schema',
'type' => 'object',
'properties' => [
'name' => ['type' => 'string'],
'age' => ['type' => 'integer'],
'email' => ['type' => 'string', 'format' => 'email'],
],
'required' => ['name', 'email'],
'additionalProperties' => false,
],
];
yield 'all required properties' => [
'properties' => [
new class(name: 'id', required: true) extends SchemaProperty
{
public function toArray(): array
{
return ['type' => 'string', 'format' => 'uuid'];
}
},
new class(name: 'name', required: true) extends SchemaProperty
{
public function toArray(): array
{
return ['type' => 'string'];
}
},
new class(name: 'email', required: true) extends SchemaProperty
{
public function toArray(): array
{
return ['type' => 'string', 'format' => 'email'];
}
},
],
'expected' => [
'$schema' => 'https://json-schema.org/draft/2020-12/schema',
'type' => 'object',
'properties' => [
'id' => ['type' => 'string', 'format' => 'uuid'],
'name' => ['type' => 'string'],
'email' => ['type' => 'string', 'format' => 'email'],
],
'required' => ['id', 'name', 'email'],
'additionalProperties' => false,
],
];
}
}

8
tests/E2E/README.md Normal file
View File

@@ -0,0 +1,8 @@
**To be determined.**
Tasks:
- [ ] Set up a testing repository.
- [ ] Make a testing init script:
- [ ] Clone the repo.
- [ ] Install Dusk or Playwright.
- [ ] Make E2E tests.

View File

@@ -0,0 +1,186 @@
<?php
namespace Sunchayn\Nimbus\Tests\Integration;
use Generator;
use Illuminate\Support\Facades\Vite;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use Sunchayn\Nimbus\Http\Web\Controllers\NimbusIndexController;
use Sunchayn\Nimbus\Modules\Routes\Actions\BuildCurrentUserAction;
use Sunchayn\Nimbus\Modules\Routes\Actions\BuildGlobalHeadersAction;
use Sunchayn\Nimbus\Modules\Routes\Actions\DisableThirdPartyUiAction;
use Sunchayn\Nimbus\Modules\Routes\Actions\ExtractRoutesAction;
use Sunchayn\Nimbus\Modules\Routes\Collections\ExtractedRoutesCollection;
use Sunchayn\Nimbus\Modules\Routes\Exceptions\RouteExtractionException;
use Sunchayn\Nimbus\Modules\Routes\Services\IgnoredRoutesService;
use Sunchayn\Nimbus\Tests\TestCase;
#[CoversClass(NimbusIndexController::class)]
#[CoversClass(NimbusIndexController::class)]
class NimbusIndexTest extends TestCase
{
protected function defineRoutes($router): void
{
$router
->post('/api/test', fn () => response()->json(['message' => 'success']))
->name('api.test');
}
protected function setUp(): void
{
parent::setUp();
// Mock Vite to prevent asset loading issues
Vite::shouldReceive('useBuildDirectory')->with('/vendor/nimbus')->once();
Vite::shouldReceive('useHotFile')->with(base_path('/vendor/sunchayn/nimbus/resources/dist/hot'))->once();
Vite::shouldReceive('__invoke')->andReturn('<script src="/nimbus/app.js"></script>');
}
#[DataProvider('indexRouteProvider')]
public function test_it_loads_view_correctly(string $uri): void
{
// Arrange
$disableThirdPartyUiActionSpy = $this->spy(DisableThirdPartyUiAction::class);
$ignoreRoutesServiceSpy = $this->spy(IgnoredRoutesService::class);
$buildGlobalHeadersActionMock = $this->mock(BuildGlobalHeadersAction::class);
$buildCurrentUserActionMock = $this->mock(BuildCurrentUserAction::class);
$extractRoutesActionMock = $this->mock(ExtractRoutesAction::class);
// Anticipate
$buildGlobalHeadersActionMock->shouldReceive('execute')->andReturn(['::global-headers::']);
$buildCurrentUserActionMock->shouldReceive('execute')->andReturn(['::current-user::']);
$extractedRoutesCollectionStub = new class extends ExtractedRoutesCollection
{
public function toFrontendArray(): array
{
return ['::extracted-routes::'];
}
};
$extractRoutesActionMock->shouldReceive('execute')->andReturn($extractedRoutesCollectionStub);
// Act
$response = $this->get(route('nimbus.index').$uri);
// Assert
$response->assertStatus(200);
$response->assertViewIs('nimbus::app');
$response->assertViewHas('routes', ['::extracted-routes::']);
$response->assertViewHas('headers', ['::global-headers::']);
$response->assertViewHas('currentUser', ['::current-user::']);
$disableThirdPartyUiActionSpy->shouldHaveReceived('execute')->once();
$ignoreRoutesServiceSpy->shouldNotHaveReceived('execute');
}
public static function indexRouteProvider(): Generator
{
yield 'index route handles route extraction exception' => [
'uri' => '/',
];
yield 'index route with catch all parameter' => [
'uri' => '/some/deep/path',
];
}
public function test_it_ignores_routes(): void
{
// Arrange
$ignoreData = [
'uri' => '/api/test',
'methods' => ['POST'],
'reason' => 'Test ignore',
];
$url = route('nimbus.index', ['ignore' => urlencode(json_encode($ignoreData))]);
// Act
$response = $this->get($url);
// Assert
$response
->assertStatus(302)
->assertRedirect('/nimbus');
}
public function test_it_catches_extraction_errors(): void
{
// Arrange
$disableThirdPartyUiActionSpy = $this->spy(DisableThirdPartyUiAction::class);
$ignoreRoutesServiceSpy = $this->spy(IgnoredRoutesService::class);
$buildGlobalHeadersActionSpy = $this->spy(BuildGlobalHeadersAction::class);
$buildCurrentUserActionSpy = $this->spy(BuildCurrentUserAction::class);
$extractionRoutesActionMock = $this->mock(ExtractRoutesAction::class);
$exception = new class(message: fake()->words(asText: true), routeUri: fake()->url(), routeMethods: fake()->words(2), controllerClass: fake()->word(), controllerMethod: fake()->word(), suggestedSolution: fake()->words(asText: true)) extends RouteExtractionException {};
// Anticipate
$extractionRoutesActionMock->shouldReceive('execute')->andThrow($exception);
// Act
$response = $this->get(route('nimbus.index'));
// Assert
$response->assertStatus(200);
$response->assertViewIs('nimbus::app');
$response->assertViewHas(
'routeExtractorException',
[
'exception' => [
'message' => $exception->getMessage(),
'previous' => $exception->getPrevious() ? [
'message' => $exception->getPrevious()->getMessage(),
'file' => $exception->getPrevious()->getFile(),
'line' => $exception->getPrevious()->getLine(),
] : null,
],
'routeContext' => $exception->getRouteContext(),
'suggestedSolution' => $exception->getSuggestedSolution(),
'ignoreData' => $exception->getIgnoreData(),
],
);
$disableThirdPartyUiActionSpy->shouldHaveReceived('execute')->once();
$ignoreRoutesServiceSpy->shouldNotHaveReceived('execute');
$buildGlobalHeadersActionSpy->shouldNotHaveReceived('execute');
$buildCurrentUserActionSpy->shouldNotHaveReceived('execute');
}
public function test_it_integrates(): void
{
// Act
$response = $this->get(route('nimbus.index'));
// Assert
$response->assertStatus(200);
$response->assertViewIs('nimbus::app');
$response->assertViewHas(['routes', 'headers', 'currentUser']);
}
}

View File

@@ -0,0 +1,231 @@
<?php
namespace Sunchayn\Nimbus\Tests\Integration;
use Generator;
use Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull;
use Illuminate\Foundation\Http\Middleware\TrimStrings;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Route;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use Sunchayn\Nimbus\Http\Api\Relay\NimbusRelayController;
use Sunchayn\Nimbus\Http\Api\Relay\NimbusRelayRequest;
use Sunchayn\Nimbus\Http\Api\Relay\RelayResponseResource;
use Sunchayn\Nimbus\Modules\Relay\Actions\RequestRelayAction;
use Sunchayn\Nimbus\Modules\Relay\Authorization\AuthorizationTypeEnum;
use Sunchayn\Nimbus\Modules\Relay\DataTransferObjects\RelayedRequestResponseData;
use Sunchayn\Nimbus\Modules\Relay\ValueObjects\PrintableResponseBody;
use Sunchayn\Nimbus\Modules\Relay\ValueObjects\ResponseCookieValueObject;
use Sunchayn\Nimbus\NimbusServiceProvider;
use Sunchayn\Nimbus\Tests\TestCase;
#[CoversClass(NimbusRelayController::class)]
#[CoversClass(NimbusRelayRequest::class)]
#[CoversClass(RelayResponseResource::class)]
#[CoversClass(NimbusServiceProvider::class)]
class NimbusRelayTest extends TestCase
{
#[DataProvider('relayRequestProvider')]
public function test_it_relays_requests(array $payload, RelayedRequestResponseData $relayResponseStub): void
{
// Arrange
$requestRelayActionMock = $this->mock(RequestRelayAction::class);
// We want to test that the logic is resilient with or without these middlewares.
$this->withoutMiddleware(TrimStrings::class);
$this->withoutMiddleware(ConvertEmptyStringsToNull::class);
// Anticipate
$requestRelayActionMock->shouldReceive('execute')->withAnyArgs()->andReturn($relayResponseStub);
// Act
$response = $this->post(
route('nimbus.api.relay'),
$payload,
[
'Content-Type' => 'multipart/form-data',
'Accept' => 'application/json',
]
);
// Assert
$response->assertStatus(200);
$response->assertJson([
'statusCode' => $relayResponseStub->statusCode,
'statusText' => $relayResponseStub->statusText,
'body' => $relayResponseStub->body->toPrettyJSON(),
'headers' => [
[
'key' => 'header1',
'value' => 'value1',
],
],
'cookies' => collect($relayResponseStub->cookies)->map(fn ($cookie) => $cookie->toArray())->all(),
'duration' => $relayResponseStub->durationMs,
'timestamp' => $relayResponseStub->timestamp,
]);
}
public static function relayRequestProvider(): Generator
{
yield 'POST request without authorization' => [
'payload' => [
'method' => 'POST',
'endpoint' => '/test-endpoint',
'body' => ['test' => 'data'],
'headers' => [
['key' => 'Content-Type', 'value' => 'application/json'],
],
],
'relayResponseStub' => new RelayedRequestResponseData(
statusCode: 200,
statusText: 'OK',
body: new PrintableResponseBody('Hey!'),
headers: [
'header1' => ['value1'],
],
durationMs: fake('en')->randomFloat(),
timestamp: fake('en')->dateTime()->getTimestamp(),
cookies: [
new ResponseCookieValueObject(
key: 'cookie1',
rawValue: '::value::',
prefix: '::prefix::',
),
],
),
];
yield 'GET request with Bearer authorization' => [
'payload' => [
'method' => 'GET',
'endpoint' => '/protected-endpoint',
'authorization' => [
'type' => AuthorizationTypeEnum::Bearer->value,
'value' => 'test-token-123',
],
],
'relayResponseStub' => new RelayedRequestResponseData(
statusCode: 200,
statusText: 'OK',
body: new PrintableResponseBody('Authentoicated!'),
headers: [
'header1' => ['value1'],
],
durationMs: fake('en')->randomFloat(),
timestamp: fake('en')->dateTime()->getTimestamp(),
cookies: [
new ResponseCookieValueObject(
key: 'cookie1',
rawValue: '::value::',
prefix: '::prefix::',
),
],
),
];
yield 'GET request with invalid endpoint' => [
'payload' => [
'method' => 'GET',
'endpoint' => '/non-existent-endpoint',
'body' => ' ',
],
'relayResponseStub' => new RelayedRequestResponseData(
statusCode: 404,
statusText: 'Not Found',
body: new PrintableResponseBody('Not Found!'),
headers: [
'header1' => ['value1'],
],
durationMs: fake('en')->randomFloat(),
timestamp: fake('en')->dateTime()->getTimestamp(),
cookies: [],
),
];
}
#[DataProvider('validationFailureProvider')]
public function test_it_validates_requests(array $payload, array $expectedErrors): void
{
// Act
$response = $this->postJson(route('nimbus.api.relay'), $payload);
// Assert
$response->assertStatus(422);
$response->assertJsonValidationErrors($expectedErrors);
}
public static function validationFailureProvider(): Generator
{
yield 'missing method' => [
'payload' => [
'endpoint' => '/test-endpoint',
],
'expectedErrors' => ['method'],
];
yield 'missing endpoint' => [
'payload' => [
'method' => 'GET',
],
'expectedErrors' => ['endpoint'],
];
yield 'invalid authorization type' => [
'payload' => [
'method' => 'GET',
'endpoint' => '/test-endpoint',
'authorization' => [
'type' => 'invalid-type',
'value' => 'test-value',
],
],
'expectedErrors' => ['authorization.type'],
];
}
public function test_it_integrates(): void
{
// Arrange
Route::get('/test-endpoint', fn () => response()->json(['message' => 'success']))
->name('test.endpoint');
$payload = [
'method' => 'GET',
'endpoint' => '/test-endpoint',
'body' => ['test' => 'data'],
];
Http::fake([
'test-endpoint?test=data' => Http::response('Hey!', 200),
]);
// Act
$response = $this->postJson(route('nimbus.api.relay'), $payload);
// Assert
$response->assertStatus(200);
$response->assertJsonStructure([
'statusCode',
'statusText',
'body',
'headers',
'cookies',
'duration',
'timestamp',
]);
}
}

47
tests/TestCase.php Normal file
View File

@@ -0,0 +1,47 @@
<?php
namespace Sunchayn\Nimbus\Tests;
use Illuminate\Support\Facades\Http;
use Mockery;
use Orchestra\Testbench\TestCase as BaseTestCase;
use Sunchayn\Nimbus\NimbusServiceProvider;
class TestCase extends BaseTestCase
{
protected function setUp(): void
{
parent::setUp();
config([
'nimbus.prefix' => 'nimbus',
'nimbus.routes.prefix' => 'api',
'nimbus.routes.versioned' => false,
'nimbus.headers' => [
'x-request-id' => 'uuid',
'x-session-id' => 'uuid',
],
'force',
]);
Http::preventStrayRequests();
}
protected function tearDown(): void
{
if ($container = Mockery::getContainer()) {
$this->addToAssertionCount($container->mockery_getExpectationCount());
}
Mockery::close();
parent::tearDown();
}
protected function getPackageProviders($app): array
{
return [
NimbusServiceProvider::class,
];
}
}

51
tests/phpunit.xml.dist Normal file
View File

@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/11.3/phpunit.xsd"
backupGlobals="false"
bootstrap="../vendor/autoload.php"
colors="true"
processIsolation="false"
stopOnFailure="false"
failOnWarning="true"
failOnRisky="true"
failOnEmptyTestSuite="true"
beStrictAboutOutputDuringTests="true"
cacheDirectory=".phpunit.cache"
backupStaticProperties="false"
displayDetailsOnTestsThatTriggerDeprecations="true"
displayDetailsOnTestsThatTriggerErrors="true"
displayDetailsOnTestsThatTriggerNotices="true"
displayDetailsOnTestsThatTriggerWarnings="true"
displayDetailsOnPhpunitDeprecations="true"
>
<testsuites>
<testsuite name="Functional">
<directory>App</directory>
</testsuite>
<testsuite name="Integration">
<directory>Integration</directory>
</testsuite>
<testsuite name="E2E">
<directory>E2E</directory>
</testsuite>
</testsuites>
<logging>
<junit outputFile="build/report.junit.xml"/>
</logging>
<source>
<include>
<directory suffix=".php">../src</directory>
</include>
<exclude>
<directory suffix=".php">../src/IntellisenseProviders</directory>
</exclude>
</source>
<php>
<server name="APP_ENV" value="testing"/>
<server name="APP_DEBUG" value="true"/>
<server name="APP_KEY" value="base64:N93kQtRXBdanjTYl9XsDGnhiy5diPi+5G3diY/Qy8ZM="/>
<server name="BCRYPT_ROUNDS" value="4"/>
<server name="CACHE_DRIVER" value="array"/>
</php>
</phpunit>

View File

@@ -0,0 +1 @@
// Generated on {{ date }}