From 5e3e9179395063582f5670de1c0f15384e6952e8 Mon Sep 17 00:00:00 2001 From: Alonzo C Turner Date: Fri, 18 Jul 2025 17:34:17 -0500 Subject: [PATCH 1/4] update unit tests --- composer.json | 3 +- composer.lock | 57 ++++++++++++++++++- config/unit.php | 56 ++++--------------- src/Controllers/Factory.php | 2 +- src/Controllers/Loader.php | 18 ++++++ tests/unit/ApplicationTest.php | 44 +++++---------- tests/unit/BootstrapTest.php | 2 +- tests/unit/Controllers/FactoryTest.php | 34 ++++++++++++ tests/unit/Controllers/LoaderTest.php | 41 ++++++++++++++ tests/unit/Namespaces/ResolverTest.php | 77 ++++++++++++++++++++++++++ 10 files changed, 254 insertions(+), 80 deletions(-) create mode 100644 tests/unit/Controllers/FactoryTest.php create mode 100644 tests/unit/Controllers/LoaderTest.php create mode 100644 tests/unit/Namespaces/ResolverTest.php 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/src/Controllers/Factory.php b/src/Controllers/Factory.php index 1b88d75..c6acf82 100644 --- a/src/Controllers/Factory.php +++ b/src/Controllers/Factory.php @@ -8,7 +8,7 @@ class Factory { - public function __construct(private Closure $resolver) + public function __construct(private readonly Closure $resolver) {} public function get(string $class): Controller diff --git a/src/Controllers/Loader.php b/src/Controllers/Loader.php index 300ce1c..a20b02f 100644 --- a/src/Controllers/Loader.php +++ b/src/Controllers/Loader.php @@ -8,14 +8,32 @@ class Loader { + /** + * Can only be instantiated using the public static create method + * + * @param Controller $controller + * @param mixed $params + */ private function __construct(private Controller $controller, private mixed $params) {} + /** + * @param Router $router The symfony router component + * @param Request $request The symfony request component + * + * @return array The route parameters + */ public static function resolveController(Router $router, Request $request): array { return $router->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\\'); + } +} From d1c45c9776bfe5ac88f07277fc771ff6e718c3a1 Mon Sep 17 00:00:00 2001 From: Alonzo C Turner Date: Fri, 18 Jul 2025 17:59:30 -0500 Subject: [PATCH 2/4] add github action for running unit tests --- .github/workflows/tests-unit.yml | 26 ++++++++++++++++++++++++++ docker-compose.yml | 2 ++ 2 files changed, 28 insertions(+) create mode 100644 .github/workflows/tests-unit.yml 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/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 + From c58d3385b8fb81b356688bdd5acf595a95874a45 Mon Sep 17 00:00:00 2001 From: Alonzo C Turner Date: Fri, 18 Jul 2025 18:06:13 -0500 Subject: [PATCH 3/4] purge the bootstrap file --- phpunit.xml | 2 +- tests/unit/bootstrap.php | 8 -------- 2 files changed, 1 insertion(+), 9 deletions(-) delete mode 100644 tests/unit/bootstrap.php diff --git a/phpunit.xml b/phpunit.xml index 1a58321..23da486 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,7 +1,7 @@ load(); From f2bb2dd86e3bc002dc977921f7ae97e50f2f8e10 Mon Sep 17 00:00:00 2001 From: Alonzo C Turner Date: Fri, 18 Jul 2025 18:12:54 -0500 Subject: [PATCH 4/4] update readme with action status --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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