diff --git a/.github/workflows/tests-unit.yml b/.github/workflows/tests-unit.yml new file mode 100644 index 0000000..840ddc9 --- /dev/null +++ b/.github/workflows/tests-unit.yml @@ -0,0 +1,26 @@ +name: Run Unit Tests + +on: + pull_request: + types: + - "opened" + - "synchronize" + branches: + - master + +jobs: + phpunit: + name: PHP Unit Test + runs-on: ubuntu-22.04 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Create PHP Environment + run: docker compose up -d + + - name: Run Composer Install + run: docker exec app_engine_php_apache composer install + + - name: Run Unit Tests + run: docker exec app_engine_php_apache vendor/bin/phpunit diff --git a/README.md b/README.md index 9ba754a..7006cb7 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # Subtext App Engine ## A tiny framework for building PHP based web applications. +![workflow](https://github.com/subtext/app-engine/actions/workflows/tests-unit.yml/badge.svg) ### Installation ```shell @@ -25,4 +26,4 @@ You would then simply need to create the HomeController class extending from the Subtext\Base\Controller class. Add models and views as necessary to create any dependencies for your controllers as they will be the primary means of manipulating the application. For more information on configuring routes see: -https://symfony.com/doc/4.3/components/routing.html \ No newline at end of file +https://symfony.com/doc/4.3/components/routing.html diff --git a/composer.json b/composer.json index 0bad10b..ae5df11 100644 --- a/composer.json +++ b/composer.json @@ -33,6 +33,7 @@ "vlucas/phpdotenv": "^5.6", "laravel/pint": "^1.22", "robmorgan/phinx": "^0.16.8", - "fakerphp/faker": "^1.24" + "fakerphp/faker": "^1.24", + "mikey179/vfsstream": "^1.6.12" } } diff --git a/composer.lock b/composer.lock index bfbea4e..2880e89 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "0274e4fb17c815f71138f9f6cd9a9808", + "content-hash": "9ca51c5d46a3781f7d7784276b6edaff", "packages": [ { "name": "laravel/serializable-closure", @@ -1607,6 +1607,58 @@ ], "time": "2025-05-20T12:55:37+00:00" }, + { + "name": "mikey179/vfsstream", + "version": "v1.6.12", + "source": { + "type": "git", + "url": "https://github.com/bovigo/vfsStream.git", + "reference": "fe695ec993e0a55c3abdda10a9364eb31c6f1bf0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/bovigo/vfsStream/zipball/fe695ec993e0a55c3abdda10a9364eb31c6f1bf0", + "reference": "fe695ec993e0a55c3abdda10a9364eb31c6f1bf0", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpunit/phpunit": "^7.5||^8.5||^9.6", + "yoast/phpunit-polyfills": "^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.6.x-dev" + } + }, + "autoload": { + "psr-0": { + "org\\bovigo\\vfs\\": "src/main/php" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Frank Kleine", + "homepage": "http://frankkleine.de/", + "role": "Developer" + } + ], + "description": "Virtual file system to mock the real file system in unit tests.", + "homepage": "http://vfs.bovigo.org/", + "support": { + "issues": "https://github.com/bovigo/vfsStream/issues", + "source": "https://github.com/bovigo/vfsStream/tree/master", + "wiki": "https://github.com/bovigo/vfsStream/wiki" + }, + "time": "2024-08-29T18:43:31+00:00" + }, { "name": "myclabs/deep-copy", "version": "1.13.3", @@ -4076,8 +4128,7 @@ "prefer-stable": false, "prefer-lowest": false, "platform": { - "php": ">= 8.4", - "ext-pdo": "*" + "php": ">= 8.4" }, "platform-dev": {}, "plugin-api-version": "2.6.0" diff --git a/config/unit.php b/config/unit.php index 413fda8..4b9f7ea 100644 --- a/config/unit.php +++ b/config/unit.php @@ -11,37 +11,22 @@ use Monolog\Handler\StreamHandler; use Monolog\Level; use Monolog\Logger; -use Subtext\AppEngine\Controllers\Factory; +use Subtext\AppEngine\Controller; use Subtext\AppEngine\Controllers\Loader; -use Symfony\Component\Config\FileLocator; -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\Routing\Exception\RouteNotFoundException; -use Symfony\Component\Routing\Loader\PhpFileLoader; -use Symfony\Component\Routing\RequestContext; -use Symfony\Component\Routing\Router; +use Symfony\Component\HttpFoundation\Response; use function DI\factory; return [ - Factory::class => factory(function (ContainerInterface $c) { - return new Factory(function (string $class) use ($c) { - if (!$c->has($class)) { - throw new RouteNotFoundException(); - } - return $c->get($class); - }); - }), - Loader::class => factory(function ( - Router $router, - Request $request, - Factory $factory - ) { - $params = Loader::resolveController($router, $request); - $controller = ($params['_controller'] ?? null); - if (!$controller) { - throw new RouteNotFoundException(); - } - return Loader::create($factory->get($controller), $params); + Loader::class => factory(function () { + return Loader::create( + new class () extends Controller { + public function execute(mixed $params): Response + { + return new Response('Hello World'); + } + }, [] + ); }), LoggerInterface::class => factory(function(ContainerInterface $c) { $logger = new Logger('debug'); @@ -52,23 +37,4 @@ return $logger; }), - Request::class => factory([Request::class, 'createFromGlobals']), - RequestContext::class => factory( - function (ContainerInterface $c) { - $context = new RequestContext(); - $context->fromRequest($c->get(Request::class)); - - return $context; - } - ), - Router::class => factory( - function (ContainerInterface $c) { - return new Router( - new PhpFileLoader(new FileLocator([dirname(__DIR__)])), - 'config/routes.php', - [], - $c->get(RequestContext::class) - ); - } - ), ]; diff --git a/docker-compose.yml b/docker-compose.yml index e823f45..5ccbba1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,7 +10,9 @@ services: VIRTUAL_ADMIN: alonzo.turner@subtext.productions PHP_IDE_CONFIG: serverName=AppEngine APP_CONFIG: unit.php + working_dir: /var/www volumes: - ./:/var/www ports: - 80:80 + diff --git a/phpunit.xml b/phpunit.xml index 1a58321..23da486 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,7 +1,7 @@ matchRequest($request); } + /** + * @param Controller $controller The controller to be passed to the app + * @param mixed $params The route params as an array + * + * @return self + */ public static function create(Controller $controller, mixed $params): self { return new self($controller, $params); diff --git a/tests/unit/ApplicationTest.php b/tests/unit/ApplicationTest.php index 35098f0..cbf6356 100644 --- a/tests/unit/ApplicationTest.php +++ b/tests/unit/ApplicationTest.php @@ -8,6 +8,7 @@ use RuntimeException; use Subtext\AppEngine\Application; use Subtext\AppEngine\Controller; +use Subtext\AppEngine\Controllers\Loader; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Router; @@ -37,24 +38,17 @@ public function testExecute(): void $controller = $this->createMock(Controller::class); $controller->expects($this->once()) ->method('execute') + ->with([]) ->willReturn($response); - $container = $this->createMock(Container::class); - $container->expects($this->once()) - ->method('has') - ->with('UnitController') - ->willReturn(true); - $container->expects($this->once()) - ->method('get') - ->with('UnitController') + $loader = $this->createMock(Loader::class); + $loader->expects($this->once()) + ->method('getController') ->willReturn($controller); + $loader->expects($this->once()) + ->method('getParams') + ->willReturn([]); $logger = $this->createMock(LoggerInterface::class); - $request = $this->createMock(Request::class); - $router = $this->createMock(Router::class); - $router->expects($this->once()) - ->method('matchRequest') - ->with($request) - ->willReturn(['_controller' => 'UnitController']); - $app = new Application($container, $logger, $request, $router); + $app = new Application($loader, $logger); ob_start(); $app->execute(); $actual = ob_get_clean(); @@ -67,22 +61,14 @@ public function testExecute(): void * @covers ::validateRequestUri * @throws Exception */ - public function testExecuteWillThrowMissingControllerException(): void + public function testExecuteWillCatchAnyException(): void { - $container = $this->createMock(Container::class); - $container->expects($this->once()) - ->method('has') - ->with('UnitController') - ->willReturn(false); + $loader = $this->createMock(Loader::class); + $loader->expects($this->once()) + ->method('getController') + ->willThrowException(new ResourceNotFoundException()); $logger = $this->createMock(LoggerInterface::class); - $request = $this->createMock(Request::class); - $router = $this->createMock(Router::class); - $router->expects($this->once()) - ->method('matchRequest') - ->with($request) - ->willReturn(['_controller' => 'UnitController']); - - $app = new Application($container, $logger, $request, $router); + $app = new Application($loader, $logger); try { $app->execute(); }catch (Throwable $e) { diff --git a/tests/unit/BootstrapTest.php b/tests/unit/BootstrapTest.php index fd9e462..40414ca 100644 --- a/tests/unit/BootstrapTest.php +++ b/tests/unit/BootstrapTest.php @@ -42,7 +42,7 @@ public function testGetContainer() public function testGetApplication() { $rootPath = dirname(__DIR__, 2); - $bootstrap = new Bootstrap($rootPath); + $bootstrap = new Bootstrap($rootPath, 'unit.php'); $app = $bootstrap->application; $this->assertInstanceOf(Application::class, $app); } diff --git a/tests/unit/Controllers/FactoryTest.php b/tests/unit/Controllers/FactoryTest.php new file mode 100644 index 0000000..d95a8c2 --- /dev/null +++ b/tests/unit/Controllers/FactoryTest.php @@ -0,0 +1,34 @@ +assertInstanceOf(Controller::class, $unit->get('')); + } + + public function testWillThrowRuntimeException(): void + { + $unit = new Factory(function(string $class) { + return null; + }); + $this->expectException(RuntimeException::class); + $unit->get('foobar'); + } +} diff --git a/tests/unit/Controllers/LoaderTest.php b/tests/unit/Controllers/LoaderTest.php new file mode 100644 index 0000000..421ad91 --- /dev/null +++ b/tests/unit/Controllers/LoaderTest.php @@ -0,0 +1,41 @@ +assertInstanceOf(Loader::class, $unit); + $this->assertInstanceOf(Controller::class, $unit->getController()); + $this->assertEquals([], $unit->getParams()); + } + + public function testResolveController(): void + { + $expected = ['_controller' => 'Subtext\\AppEngine\\Controllers\\HelloWorld']; + $request = $this->createMock(Request::class); + $router = $this->createMock(Router::class); + $router->expects($this->once()) + ->method('matchRequest') + ->with($request) + ->willReturn($expected); + $actual = Loader::resolveController($router, $request); + $this->assertEquals($expected, $actual); + } +} diff --git a/tests/unit/Namespaces/ResolverTest.php b/tests/unit/Namespaces/ResolverTest.php new file mode 100644 index 0000000..ad9d637 --- /dev/null +++ b/tests/unit/Namespaces/ResolverTest.php @@ -0,0 +1,77 @@ + [ + 'www' => [ + 'src' => [ + 'Alpha.php' => 'alpha', + 'Beta.php' => 'beta', + 'Gamma.php' => 'gamma', + 'Colors' => [ + 'Red.php' => 'red', + 'Green.php' => 'green', + 'Blue.php' => 'blue', + ], + ], + ], + ], + ]); + parent::setUp(); + } + + public function testCanResolveClassesFromNamespaces(): void + { + $unit = new Resolver('Subtext\\FooBar\\', vfsStream::url('root/var/www/src')); + $actual = $unit->getClassesFrom('Subtext\\FooBar\\'); + $this->assertEquals('Subtext\\FooBar\\Alpha', $actual[0]); + $this->assertEquals('Subtext\\FooBar\\Beta', $actual[1]); + $this->assertEquals('Subtext\\FooBar\\Gamma', $actual[2]); + } + + public function testCanResolveClassesRecursively(): void + { + $unit = new Resolver('Subtext\\FooBar\\', vfsStream::url('root/var/www/src')); + $actual = $unit->getClassesFrom('Subtext\\FooBar\\', true); + $this->assertEquals('Subtext\\FooBar\\Colors\\Red', $actual[3]); + $this->assertEquals('Subtext\\FooBar\\Colors\\Green', $actual[4]); + $this->assertEquals('Subtext\\FooBar\\Colors\\Blue', $actual[5]); + } + + public function testWillEnforceRootNamespace(): void + { + $unit = new Resolver('Subtext\\FooBar\\', vfsStream::url('root/var')); + $this->expectException(InvalidArgumentException::class); + $unit->getClassesFrom('Microsoft\\Teams\\', true); + } + + public function testWillEnforceNamespaceTrailingSlash(): void + { + $this->expectException(InvalidArgumentException::class); + $unit = new Resolver('Subtext\\FooBar', vfsStream::url('root/var')); + } + + public function testWillValidateNamespaceRootDirectory(): void + { + $this->expectException(InvalidArgumentException::class); + $unit = new Resolver('Subtext\\FooBar\\', vfsStream::url('foobar')); + } + + public function testWillThrowExceptionForInvalidPsr4Namespace(): void + { + $unit = new Resolver('Subtext\\FooBar\\', vfsStream::url('root/var/www/src/Colors')); + $this->expectException(InvalidArgumentException::class); + $unit->getClassesFrom('Subtext\\FooBar\\Colors\\'); + } +} diff --git a/tests/unit/bootstrap.php b/tests/unit/bootstrap.php deleted file mode 100644 index d0b04d4..0000000 --- a/tests/unit/bootstrap.php +++ /dev/null @@ -1,8 +0,0 @@ -load();