diff --git a/composer.json b/composer.json index 88e4e42..04c2248 100644 --- a/composer.json +++ b/composer.json @@ -49,6 +49,7 @@ "minimum-stability": "stable", "scripts": { "cs": "@php ./vendor/squizlabs/php_codesniffer/bin/phpcs", + "fix:cs": "@php ./vendor/squizlabs/php_codesniffer/bin/phpcbf", "psalm": "@php ./vendor/vimeo/psalm/psalm --no-cache --output-format=compact", "tests": "@php ./vendor/phpunit/phpunit/phpunit", "tests:no-cov": "@php ./vendor/phpunit/phpunit/phpunit --no-coverage", diff --git a/src/App.php b/src/App.php index 2d2a7c5..4910847 100644 --- a/src/App.php +++ b/src/App.php @@ -353,6 +353,9 @@ public function sharePackage(Modularity\Package $package, string ...$contexts): } /** + * If context is correct + * If package is booted + * Adds the Package Container to the App Container * @param Modularity\Package $package * @param string ...$contexts * @return App @@ -475,7 +478,6 @@ private function addModularityModule( return $this; } - $this->initializeModularity($early); $this->ensureWillBoot(); @@ -634,6 +636,7 @@ private function handleModularityBoot( : Modularity\Package::MODULE_EXECUTED; $this->syncModularityStatus($package, $status); + // TODO: this if condition is running twice when called from sharePackage or sharePackageToBoot if ( $package->statusIs(Modularity\Package::STATUS_BOOTED) || ($onPackageReady && $package->statusIs(Modularity\Package::STATUS_INITIALIZED)) diff --git a/tests/example/cases/ProjectTest.php b/tests/example/cases/ProjectTest.php index 3a056ee..da6831e 100644 --- a/tests/example/cases/ProjectTest.php +++ b/tests/example/cases/ProjectTest.php @@ -26,11 +26,23 @@ public function executeProject(): void fwrite(STDOUT, print_r(app()->resolve(Logger::class)->allLogs(), true)); } + protected function checkLateModule(bool $expectToBeThere = false): void + { + if (!$expectToBeThere) { + static::assertTrue(app()->status()->isBooted()); + static::assertNull(app()->resolve('dummy-test')); + return; + } + static::assertNotNull(app()->resolve('dummy-test')); + static::assertEquals(app()->resolve('dummy-test')->text(), 'Hello World'); + } + /** * @return void */ protected function onBeforePlugins(): void { + $this->checkLateModule(); } /** @@ -38,6 +50,7 @@ protected function onBeforePlugins(): void */ protected function onAfterPlugins(): void { + $this->checkLateModule(); } /** @@ -45,6 +58,7 @@ protected function onAfterPlugins(): void */ protected function onAfterTheme(): void { + $this->checkLateModule(); } /** @@ -61,6 +75,8 @@ protected function onAfterInit(): void static::assertTrue($logger->hasLog("{$pre} 'Lorem Ipsum'")); static::assertTrue($logger->hasLog("{$pre} 'Dolor Sit Amet'")); static::assertFalse($logger->hasLog("{$pre} '[From Plugin 2] Plugin Two is Good For You'")); + + $this->checkLateModule(true); } /** diff --git a/tests/example/sources/libraries/modularity-lib/src/LateModule.php b/tests/example/sources/libraries/modularity-lib/src/LateModule.php index e1c0c3d..f2539f9 100644 --- a/tests/example/sources/libraries/modularity-lib/src/LateModule.php +++ b/tests/example/sources/libraries/modularity-lib/src/LateModule.php @@ -5,6 +5,7 @@ namespace Inpsyde\App\Tests\Project\ModularityLib; use Inpsyde\App\Tests\Project\ModularityPlugin3\Calc; +use Inpsyde\Modularity\Module\ServiceModule; use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; @@ -28,4 +29,22 @@ static function () use ($container): void { return parent::run($container); } + + public function services(): array + { + return array_merge( + parent::services(), + [ + // phpcs:ignore Inpsyde.CodeQuality.ReturnTypeDeclaration.NoReturnType + 'dummy-test' => static function () { + return new class { + public function text(): string + { + return 'Hello World'; + } + }; + }, + ] + ); + } } diff --git a/tests/src/TestCase.php b/tests/src/TestCase.php index 9e313a2..8e3493e 100644 --- a/tests/src/TestCase.php +++ b/tests/src/TestCase.php @@ -5,8 +5,15 @@ namespace Inpsyde\App\Tests; use Brain\Monkey; +use Inpsyde\Modularity\Module\ExecutableModule; +use Inpsyde\Modularity\Module\ExtendingModule; +use Inpsyde\Modularity\Module\FactoryModule; +use Inpsyde\Modularity\Module\Module; +use Inpsyde\Modularity\Module\ServiceModule; +use Inpsyde\Modularity\Properties\Properties; use Inpsyde\WpContext; use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration; +use Mockery\MockInterface; use PHPUnit\Framework\TestCase as FrameworkTestCase; abstract class TestCase extends FrameworkTestCase @@ -58,6 +65,9 @@ protected function setUp(): void 'get_stylesheet_directory_uri' => static function (): string { return get_theme_root_uri() . '/my-theme'; }, + 'plugins_url' => static function (): string { + return content_url() . '/plugins'; + }, ]); } @@ -83,4 +93,69 @@ protected function factoryContext(?string $case = null, bool $withCli = false): return $withCli ? $context->withCli() : $context; } + + /** + * @param string $basename + * @param bool $isDebug + * + * @return Properties|MockInterface + */ + protected function mockProperties( + string $basename = 'basename', + bool $isDebug = false + ): Properties { + + $stub = \Mockery::mock(Properties::class); + $stub->allows('basename')->andReturn($basename); + $stub->allows('isDebug')->andReturn($isDebug); + + return $stub; + } + + /** + * @param string $id + * @param class-string ...$interfaces + * @return Module|MockInterface + */ + protected function mockModule(string $id = 'module', string ...$interfaces): Module + { + $interfaces or $interfaces[] = Module::class; + + $stub = \Mockery::mock(...$interfaces); + $stub->allows('id')->andReturn($id); + + if (in_array(ServiceModule::class, $interfaces, true)) { + $stub->allows('services')->byDefault()->andReturn([]); + } + + if (in_array(FactoryModule::class, $interfaces, true)) { + $stub->allows('factories')->byDefault()->andReturn([]); + } + + if (in_array(ExtendingModule::class, $interfaces, true)) { + $stub->allows('extensions')->byDefault()->andReturn([]); + } + + if (in_array(ExecutableModule::class, $interfaces, true)) { + $stub->allows('run')->byDefault()->andReturn(false); + } + + return $stub; + } + + /** + * @param string ...$ids + * @return array + */ + protected function stubServices(string ...$ids): array + { + $services = []; + foreach ($ids as $id) { + $services[$id] = static function () use ($id): \ArrayObject { + return new \ArrayObject(['id' => $id]); + }; + } + + return $services; + } } diff --git a/tests/unit/AppTest.php b/tests/unit/AppTest.php new file mode 100644 index 0000000..1a8dca8 --- /dev/null +++ b/tests/unit/AppTest.php @@ -0,0 +1,356 @@ +justReturn(); + $reflectedApp = new \ReflectionClass(App::class); + $this->containerProp = $reflectedApp->getProperty('container'); + $this->containerProp->setAccessible(true); + $this->appStatusProp = $reflectedApp->getProperty('status'); + $this->appStatusProp->setAccessible(true); + $reflectedContainer = new \ReflectionClass(CompositeContainer::class); + $this->mapProp = $reflectedContainer->getProperty('map'); + $this->mapProp->setAccessible(true); + $this->appBootQueueProp = $reflectedApp->getProperty('bootQueue'); + $this->appBootQueueProp->setAccessible(true); + $this->appHandleModularityBoot = $reflectedApp->getMethod('handleModularityBoot'); + $this->appSyncModularityStatus = $reflectedApp->getMethod('syncModularityStatus'); + + $this->appStatusReflection = new \ReflectionClass(AppStatus::class); + + parent::setUp(); + } + + private function prepareSharePackageCommon(): array + { + \Brain\Monkey\Functions\stubs([ + 'plugins_url' => static function (): string { + return content_url() . '/plugins/fake'; + }, + ]); + + $context = WpContext::new()->force(WpContext::CORE); + $app = App::new(null, null, $context); + static::assertInstanceOf( + CompositeContainer::class, + $this->containerProp->getValue($app) + ); + $appModuleId = 'app-module-id'; + $containerServiceId = 'cont-service-id'; + + $appModule = $this->mockModule($appModuleId, ServiceModule::class); + $appModule->expects('services')->andReturn($this->stubServices($containerServiceId)); + $app->addEarlyModule($appModule); + + $moduleId = 'my-service-module'; + $packageServiceId = 'service-id'; + + $module = $this->mockModule($moduleId, ServiceModule::class); + $expectedReturnFromService = $this->stubServices($packageServiceId); + $module->expects('services')->andReturn($expectedReturnFromService); + + $package = Package::new($this->mockProperties())->addModule($module); + + return [ + 'app' => $app, + 'appModuleId' => $appModuleId, + 'containerServiceId' => $containerServiceId, + 'moduleId' => $moduleId, + 'packageServiceId' => $packageServiceId, + 'package' => $package, + 'expectedClassFromPackageService' => \ArrayObject::class, + ]; + } + + public function testNewWithNoContainer() + { + + $context = WpContext::new()->force(WpContext::CORE); + $app = App::new(null, null, $context); + static::assertInstanceOf( + CompositeContainer::class, + $this->containerProp->getValue($app) + ); + static::assertTrue($this->appStatusProp->getValue($app)->isIdle()); + } + + public function testAddEarlyModule() + { + $context = WpContext::new()->force(WpContext::CORE); + $app = App::new(null, null, $context); + $moduleId = 'my-early-service-module'; + $moduleServiceId = 'early-service-id'; + $module = $this->mockModule($moduleId, ServiceModule::class); + $module->expects('services')->andReturn($this->stubServices($moduleServiceId)); + $app->addEarlyModule($module); + // We expect the service is not resolvable if the App Container is not booted + static::assertEquals(null, $app->resolve($moduleServiceId)); + $app->boot(); + static::assertInstanceOf(\ArrayObject::class, $app->resolve($moduleServiceId)); + } + + /** + * + * @return void + * @throws \ReflectionException + */ + public function testAddModule() + { + $context = WpContext::new()->force(WpContext::CORE); + $app = App::new(null, null, $context); + $moduleId = 'my-early-service-module'; + $moduleServiceId = 'early-service-id'; + $module = $this->mockModule($moduleId, ServiceModule::class); + $module->expects('services')->andReturn($this->stubServices($moduleServiceId)); + $app->addModule($module); + // We expect the service is not resolvable if the App Container is not booted + static::assertEquals(null, $app->resolve($moduleServiceId)); + + // we have to force the internal status of the AppStatus + // we need $lastRun to be true when calling isThemeStep inside boot + $appStatusInternalStatusProp = $this->appStatusReflection->getProperty('status'); + $appStatusInternalStatusProp->setValue( + $this->appStatusProp->getValue($app), + AppStatus::REGISTERING_THEMES + ); + + $app->boot(); + static::assertInstanceOf(\ArrayObject::class, $app->resolve($moduleServiceId)); + } + + /** + * Scenario + * Package is booted + * Expectations + * Package services are added to the App Container + * Package does NOT receive any definitions from the WP App Container + * + * @group sharePackageToBoot + * @return void + */ + public function testSharePackageToBootWhenPackageIsBooted() + { + [ + 'app' => $app, + 'containerServiceId' => $containerServiceId, + 'packageServiceId' => $packageServiceId, + 'package' => $package, + 'expectedClassFromPackageService' => $expectedClassFromPackageService, + ] = $this->prepareSharePackageCommon(); + + // When package is booted + static::assertTrue($package->boot()); + + // When we call sharePackageToBoot and pass a booted package + static::assertInstanceOf(App::class, $app->sharePackageToBoot($package)); + + // The Services from the Package are shared to the App Container + static::assertInstanceOf($expectedClassFromPackageService, $app->resolve($packageServiceId)); + + // But, the services from the App Container are NOT added to the Package since the Package Container is ReadOnly + static::assertFalse($package->container()->has($containerServiceId)); + } + + /** + * Scenario + * Package is not booted + * The Services added to the App Container are added via addEarlyModule + * App boot is called after + * Expectations + * After App Booting + * App Container can resolve Package services + * Package can resolve App Container Services + * @group sharePackageToBoot + * @return void + */ + public function testSharePackageToBootWhenPackageIsNotBooted(): void + { + + [ + 'app' => $app, + 'containerServiceId' => $containerServiceId, + 'packageServiceId' => $packageServiceId, + 'package' => $package, + ] = $this->prepareSharePackageCommon(); + + // When we call sharePackageToBoot passing a NOT booted package + // Notice that the WP App container is not booted at this point neither + static::assertInstanceOf(App::class, $app->sharePackageToBoot($package)); + + // we don't expect the services from the Package to be in the App Container before booting the App Container + $container = $this->containerProp->getValue($app); + static::assertFalse($container->has($packageServiceId)); + + // we don't expect the package to have container to be accesible because is not booted nor built. + static::assertFalse($package->statusIs(Package::STATUS_BOOTED)); + try { + $package->container(); + } catch (\Exception $exception) { + static::assertStringContainsString( + 'Can\'t obtain the container', + $exception->getMessage() + ); + } + + // It is only connected with a new Package that is booted already + static::assertEquals([ "inpsyde-wp-app" => true ], $package->connectedPackages()); + + // We expect the Package to be added to the boot queue + /** @var \SplObjectStorage $currentQueue */ + $currentQueue = $this->appBootQueueProp->getValue($app); + static::assertTrue($currentQueue->contains($package)); + + // It seems the App Container does not have the service in the container if it is not booted + $container = $this->containerProp->getValue($app); + static::assertFalse($container->has($containerServiceId)); + + // if we boot the App container + $app->boot(); + + // We expect the App Container to have the Package services + static::assertInstanceOf(\ArrayObject::class, $app->resolve($packageServiceId)); + + // We expect the Package to be booted + static::assertTrue($package->statusIs(Package::STATUS_BOOTED)); + + // Note: we can retrieve the service from the App Container because we used App::addEarlyModule + static::assertTrue($package->container()->has($containerServiceId)); + static::assertInstanceOf(\ArrayObject::class, $app->resolve($containerServiceId)); + } + + /** + * @return void + * @group sharePackage + */ + public function testSharePackageWhenPackageIsBooted() + { + /** + * @var App $app + */ + [ + 'app' => $app, + 'containerServiceId' => $containerServiceId, + 'packageServiceId' => $packageServiceId, + 'package' => $package, + 'expectedClassFromPackageService' => $expectedClassFromPackageService, + ] = $this->prepareSharePackageCommon(); + + // When package is booted + static::assertTrue($package->boot()); + + // When we call sharePackage and pass a booted package + static::assertInstanceOf(App::class, $app->sharePackage($package)); + + // The Services from the Package are shared to the App Container + static::assertInstanceOf($expectedClassFromPackageService, $app->resolve($packageServiceId)); + + // But, the services from the App Container are NOT added to the Package since the Package Container is ReadOnly + static::assertFalse($package->container()->has($containerServiceId)); + } + + /** + * Scenario + * Package is not booted + * The Services added to the App Container are added via addEarlyModule + * App boot is called after + * Expectations + * After App Booting + * App Container can resolve Package services + * Package can resolve App Container Services + * @group sharePackage + * @return void + */ + public function testSharePackageWhenPackageIsNotBooted(): void + { + + [ + 'app' => $app, + 'containerServiceId' => $containerServiceId, + 'packageServiceId' => $packageServiceId, + 'package' => $package, + ] = $this->prepareSharePackageCommon(); + + // When we call sharePackageToBoot passing a NOT booted package + // Notice that the WP App container is not booted at this point neither + static::assertInstanceOf(App::class, $app->sharePackage($package)); + + // we don't expect the services from the Package to be in the App Container before booting the App Container + $container = $this->containerProp->getValue($app); + static::assertFalse($container->has($packageServiceId)); + + // we don't expect the package to have container to be accessible because is not booted nor built. + static::assertFalse($package->statusIs(Package::STATUS_BOOTED)); + try { + $package->container(); + } catch (\Exception $exception) { + static::assertStringContainsString( + 'Can\'t obtain the container', + $exception->getMessage() + ); + } + + // It is only connected with a new Package that is booted already + static::assertEquals([ "inpsyde-wp-app" => true ], $package->connectedPackages()); + + // we manually boot the package in here since sharePackage enqueues a callback waiting for this + $package->boot(); + + // we have to mimic the internals of the waitForPackageBoot + // (we are hardcoding a callback there) + $this->appSyncModularityStatus->invoke( + $app, + $package, + Package::MODULE_ADDED + ); + + $this->appHandleModularityBoot->invoke( + $app, + $package, + true + ); + + // package can't see the service from app container if the Wp App Container is not booted + static::assertFalse($package->container()->has($containerServiceId)); + + // if we boot the App container + $app->boot(); + + // We expect the Package to have container services + static::assertTrue($package->container()->has($containerServiceId)); + + // We expect the App Container to have the Package services + static::assertInstanceOf(\ArrayObject::class, $app->resolve($packageServiceId)); + + // Note: we can retrieve the service from the App Container because we used App::addEarlyModule + static::assertInstanceOf(\ArrayObject::class, $app->resolve($containerServiceId)); + } +}