diff --git a/src/Application.php b/src/Application.php index 0f41a49..08aeb7c 100644 --- a/src/Application.php +++ b/src/Application.php @@ -162,7 +162,7 @@ public function register($provider) } if (is_string($provider)) { - $provider = new $provider($this); + $provider = $this->make($provider); } if (method_exists($provider, 'register')) { diff --git a/src/Bootstrappers/RegisterRequestHandler.php b/src/Bootstrappers/RegisterRequestHandler.php index c3d8f32..64d1419 100644 --- a/src/Bootstrappers/RegisterRequestHandler.php +++ b/src/Bootstrappers/RegisterRequestHandler.php @@ -13,5 +13,9 @@ public function bootstrap(Application $app) if ($config->get('app.debug')) { $app->detectWhenRequestHasNotBeenHandled(); } + + $app->bind(\WP_Query::class, function () { + return $GLOBALS['wp_query']; + }); } } diff --git a/src/Exceptions/MismatchedContextException.php b/src/Exceptions/MismatchedContextException.php new file mode 100644 index 0000000..13ea170 --- /dev/null +++ b/src/Exceptions/MismatchedContextException.php @@ -0,0 +1,16 @@ +make(TimberResponse::class, [ + 'twigTemplate' => $template, + 'context' => $context, + 'status' => $statusCode, + 'headers' => $headers, + ]); } public static function route($name, $params = []) diff --git a/src/Http/Resolvers/AbstractContextResolver.php b/src/Http/Resolvers/AbstractContextResolver.php new file mode 100644 index 0000000..6b618de --- /dev/null +++ b/src/Http/Resolvers/AbstractContextResolver.php @@ -0,0 +1,102 @@ +getParameters() as $parameter) { + if (Arr::has($resolvedParameters, $parameter->getPosition())) { + continue; + } + + $type = $parameter->getType(); + + if (!$type || $type->isBuiltin()) { + continue; + } + + $className = $type->getName(); + + if (!$this->canResolveClass($className)) { + continue; + } + + try { + $context = $this->getContext(); + + if (is_null($context)) { + throw MissingContextException::forType($className, $context); + } + + if (!$this->isValidContext($context, $className)) { + throw MismatchedContextException::forIncorrectClass($className, $context); + } + + $resolvedObject = $this->resolveObject($className, $context); + + if (!is_null($resolvedObject) && !$resolvedObject instanceof $className) { + throw MismatchedContextException::forIncorrectClass($className, $resolvedObject); + } + + $resolvedParameters[$parameter->getPosition()] = $resolvedObject; + } catch (MissingContextException | MismatchedContextException $e) { + // If the context is entirely missing or mismatched, we allow null if the typehint supports it + if (!$parameter->allowsNull()) { + throw $e; + } + + $resolvedParameters[$parameter->getPosition()] = null; + } + } + + return $resolvedParameters; + } + + /** + * Get the raw context object to resolve from (e.g. WP_Post, WP_Term, WP_Query). + * Defaults to the current WordPress queried object. + * + * @return mixed + */ + protected function getContext(): mixed + { + return get_queried_object(); + } + + /** + * Determine if this resolver can handle the given class type-hint. + * + * @param string $className + * @return bool + */ + abstract protected function canResolveClass(string $className): bool; + + /** + * Determine if the current context is valid for this resolver. + * + * @param mixed $context + * @param string $className + * @return bool + */ + abstract protected function isValidContext(mixed $context, string $className): bool; + + /** + * Build the concrete object instance from the raw context. + * + * @param string $className + * @param mixed $context + * @return mixed + */ + abstract protected function resolveObject(string $className, mixed $context): mixed; +} diff --git a/src/Http/Resolvers/PostQueryResolver.php b/src/Http/Resolvers/PostQueryResolver.php new file mode 100644 index 0000000..0411c3f --- /dev/null +++ b/src/Http/Resolvers/PostQueryResolver.php @@ -0,0 +1,44 @@ +app->get(WP_Query::class); + } + + protected function resolveObject(string $className, mixed $context): mixed + { + // If they asked for the interface or the base Timber PostQuery, use the factory + if ($className === PostCollectionInterface::class || $className === TimberPostQuery::class) { + return Timber::get_posts($context); + } + + // If it's a subclass (like Rareloop\Lumberjack\PostQuery), we must instantiate it manually + // to ensure we get the correct instance type. + return new $className($context); + } +} diff --git a/src/Http/Resolvers/PostResolver.php b/src/Http/Resolvers/PostResolver.php new file mode 100644 index 0000000..e69cf9a --- /dev/null +++ b/src/Http/Resolvers/PostResolver.php @@ -0,0 +1,28 @@ +toArray(); + // Recursively walk the array, when we find something that implements the Arrayable interface // flatten it to an array. Because we're passing by reference by updating what the value of // $item is will mutate the original data structure passed in. - array_walk_recursive($context, function (&$item, $key) { + array_walk_recursive($context, function (&$item) { if ($item instanceof Arrayable || $item instanceof CollectionArrayable) { - $item = $this->flattenContextToArrays($item->toArray()); + $item = $this->flattenContextToArrays($item); } }); diff --git a/src/Http/TimberContext.php b/src/Http/TimberContext.php new file mode 100644 index 0000000..ac3d66c --- /dev/null +++ b/src/Http/TimberContext.php @@ -0,0 +1,73 @@ +items, $key, $value); + + return $this; + } + + /** + * Get an item from the context using dot-notation. + * + * @param string $key + * @param mixed $default + * @return mixed + */ + public function get($key, $default = null): mixed + { + return Arr::get($this->items, $key, $default); + } + + /** + * Determine if an item exists in the context using dot-notation. + * + * @param string $key + * @return bool + */ + public function has($key): bool + { + return Arr::has($this->items, $key); + } + + /** + * Determine if an item exists at an offset using dot-notation. + * + * @param string $key + * @return bool + */ + public function offsetExists($key): bool + { + return $this->has($key); + } + + /** + * Get an item at a given offset using dot-notation. + * + * @param string $key + * @return mixed + */ + public function offsetGet($key): mixed + { + return $this->get($key); + } +} diff --git a/src/PostQuery.php b/src/PostQuery.php new file mode 100644 index 0000000..275ccd6 --- /dev/null +++ b/src/PostQuery.php @@ -0,0 +1,12 @@ +app->singleton(TimberContext::class, fn() => new TimberContext(Timber::context())); } public function boot(Config $config) diff --git a/src/Providers/WordPressControllersServiceProvider.php b/src/Providers/WordPressControllersServiceProvider.php index 49a206f..6473737 100644 --- a/src/Providers/WordPressControllersServiceProvider.php +++ b/src/Providers/WordPressControllersServiceProvider.php @@ -11,9 +11,29 @@ use Rareloop\Lumberjack\Http\Middleware\PasswordProtected; use Laminas\Diactoros\ServerRequestFactory; use Rareloop\Router\ProvidesControllerMiddleware; +use Rareloop\Lumberjack\Http\Resolvers\PostQueryResolver; +use Rareloop\Lumberjack\Http\Resolvers\UserResolver; +use Rareloop\Lumberjack\Http\Resolvers\PostResolver; +use Rareloop\Lumberjack\Http\Resolvers\TermResolver; class WordPressControllersServiceProvider extends ServiceProvider { + public function register() + { + $this->app->bind(Invoker::class, function ($app) { + $invoker = new Invoker($app); + $resolverChain = $invoker->getParameterResolver(); + + // We iterate and prepend so that the last items in the collection end up at the top + // of the resolver chain (highest priority). + collect($this->getCoreResolvers()) + ->merge($app->get('config')->get('app.resolvers', [])) + ->each(fn($resolver) => $resolverChain->prependResolver($app->make($resolver))); + + return $invoker; + }); + } + public function boot() { add_filter('template_include', [$this, 'handleTemplateInclude']); @@ -87,8 +107,10 @@ public function handleRequest(RequestInterface $request, $controllerName, $metho $this->app->get(PasswordProtected::class), ...$middlewares, function ($request) use ($controller, $methodName) { - $invoker = new Invoker($this->app); - $output = $invoker->setRequest($request)->call([$controller, $methodName]); + $output = $this->app->make(Invoker::class) + ->setRequest($request) + ->call([$controller, $methodName]); + return ResponseFactory::create($request, $output); } ]; @@ -109,4 +131,14 @@ private function createDispatcher(array $middlewares): Dispatcher return new Dispatcher($middlewares, $resolver); } + + private function getCoreResolvers(): array + { + return [ + PostQueryResolver::class, + PostResolver::class, + TermResolver::class, + UserResolver::class, + ]; + } } diff --git a/src/Term.php b/src/Term.php new file mode 100644 index 0000000..52868dd --- /dev/null +++ b/src/Term.php @@ -0,0 +1,12 @@ +bootstrap($app); } + + #[Test] + public function it_binds_wp_query_to_the_global_variable() + { + $app = new Application(); + $config = new Config(); + $app->bind('config', $config); + + $query = new \stdClass(); + $GLOBALS['wp_query'] = $query; + + $bootstrapper = new RegisterRequestHandler(); + $bootstrapper->bootstrap($app); + + $this->assertSame($query, $app->get(\WP_Query::class)); + } } diff --git a/tests/Unit/HelpersTest.php b/tests/Unit/HelpersTest.php index c91fb11..9f22689 100644 --- a/tests/Unit/HelpersTest.php +++ b/tests/Unit/HelpersTest.php @@ -90,6 +90,7 @@ public function can_set_a_config_value_when_array_passed_to_config_helper() #[Test] public function can_get_a_timber_response() { + new Application(); $timber = \Mockery::mock('alias:' . Timber::class); $timber->shouldReceive('compile') ->with('template.twig', IsArrayContainingKeyValuePair::hasKeyValuePair('foo', 'bar')) @@ -108,6 +109,7 @@ public function can_get_a_timber_response() #[Test] public function can_get_a_timber_response_with_a_specific_status_code() { + new Application(); $timber = \Mockery::mock('alias:' . Timber::class); $timber->shouldReceive('compile') ->once() @@ -121,6 +123,7 @@ public function can_get_a_timber_response_with_a_specific_status_code() #[Test] public function can_get_a_timber_response_with_specific_headers() { + new Application(); $timber = \Mockery::mock('alias:' . Timber::class); $timber->shouldReceive('compile') ->once() diff --git a/tests/Unit/Http/Resolvers/AbstractContextResolverTest.php b/tests/Unit/Http/Resolvers/AbstractContextResolverTest.php new file mode 100644 index 0000000..1a57c30 --- /dev/null +++ b/tests/Unit/Http/Resolvers/AbstractContextResolverTest.php @@ -0,0 +1,139 @@ +app = new Application(); + $this->resolver = new TestContextResolver(); + + // We use a fresh Invoker with ONLY our test resolver to verify the base class logic + $this->invoker = new Invoker($this->app); + $this->invoker->getParameterResolver()->prependResolver($this->resolver); + } + + #[Test] + public function it_ignores_builtin_typehints(): void + { + $controller = new class { + public function handle(int $id, string $name, $noType) + { + return $id; + } + }; + + $result = $this->invoker->call([$controller, 'handle'], ['id' => 123, 'name' => 'foo', 'noType' => 'bar']); + + $this->assertSame(123, $result); + } + + #[Test] + public function it_ignores_classes_it_cannot_handle(): void + { + $this->resolver->canResolve = false; + + $controller = new class { + public function handle(Application $app) + { + return $app; + } + }; + + $result = $this->invoker->call([$controller, 'handle']); + + $this->assertSame($this->app, $result); + } + + #[Test] + public function it_throws_an_exception_if_context_is_missing_and_not_nullable(): void + { + Functions\expect('get_queried_object')->once()->andReturn(null); + + $controller = new class { + public function handle(stdClass $obj) + { + } + }; + + $this->expectException(MissingContextException::class); + $this->invoker->call([$controller, 'handle']); + } + + #[Test] + public function it_resolves_to_null_if_context_is_missing_and_nullable(): void + { + Functions\expect('get_queried_object')->once()->andReturn(null); + + $controller = new class { + public function handle(?stdClass $obj) + { + return $obj; + } + }; + + $result = $this->invoker->call([$controller, 'handle']); + + $this->assertNull($result); + } + + #[Test] + public function it_throws_an_exception_if_resolved_object_is_wrong_type(): void + { + Functions\expect('get_queried_object')->once()->andReturn(new stdClass()); + + $this->resolver->resolvedObject = new Exception(); // Not a stdClass + + $controller = new class { + public function handle(stdClass $obj) + { + } + }; + + $this->expectException(MismatchedContextException::class); + $this->invoker->call([$controller, 'handle']); + } +} + +class TestContextResolver extends AbstractContextResolver +{ + public bool $canResolve = true; + public bool $isValid = true; + public $resolvedObject; + + protected function canResolveClass(string $className): bool + { + return $this->canResolve; + } + + protected function isValidContext(mixed $context, string $className): bool + { + return $this->isValid; + } + + protected function resolveObject(string $className, mixed $context): mixed + { + return $this->resolvedObject ?? new stdClass(); + } +} diff --git a/tests/Unit/Http/Resolvers/NullableMultiParameterTest.php b/tests/Unit/Http/Resolvers/NullableMultiParameterTest.php new file mode 100644 index 0000000..ba62d9a --- /dev/null +++ b/tests/Unit/Http/Resolvers/NullableMultiParameterTest.php @@ -0,0 +1,92 @@ +app = new Application(); + $this->invoker = new Invoker($this->app); + } + + #[Test] + public function it_resolves_multiple_nullable_parameters_correctly() + { + // Simulate being on an Author page (WP_User context) + Functions\expect('get_queried_object')->andReturn(new WP_User()); + + // We'll use two real-world-like resolvers but as anonymous classes to keep it isolated + $userResolver = new class extends AbstractContextResolver { + protected function canResolveClass(string $className): bool + { + return $className === WP_User::class; + } + protected function isValidContext(mixed $context, string $className): bool + { + return is_a($context, WP_User::class); + } + protected function resolveObject(string $className, mixed $context): mixed + { + return $context; + } + }; + + $termResolver = new class extends AbstractContextResolver { + protected function canResolveClass(string $className): bool + { + return $className === WP_Term::class; + } + protected function isValidContext(mixed $context, string $className): bool + { + return is_a($context, WP_Term::class); + } + protected function resolveObject(string $className, mixed $context): mixed + { + return $context; + } + }; + + // Order matters in the chain, but with our fix it shouldn't prevent resolution + $this->invoker->getParameterResolver()->prependResolver($userResolver); + $this->invoker->getParameterResolver()->prependResolver($termResolver); + + $controller = new class { + public function handle(?WP_Term $term, ?WP_User $user) + { + return ['term' => $term, 'user' => $user]; + } + }; + + // This would have previously thrown MismatchedContextException when TermResolver tried to handle WP_User + $result = $this->invoker->call([$controller, 'handle']); + + $this->assertNull($result['term']); + $this->assertInstanceOf(WP_User::class, $result['user']); + } +} diff --git a/tests/Unit/Http/Resolvers/PostQueryResolverTest.php b/tests/Unit/Http/Resolvers/PostQueryResolverTest.php new file mode 100644 index 0000000..76af464 --- /dev/null +++ b/tests/Unit/Http/Resolvers/PostQueryResolverTest.php @@ -0,0 +1,106 @@ +app = new Application(__DIR__ . '/../../../../'); + + $config = Mockery::mock(Config::class); + $config->shouldReceive('get')->with('app.resolvers', [])->andReturn([]); + $this->app->bind('config', $config); + + $provider = new WordPressControllersServiceProvider($this->app); + $provider->register(); + + $this->invoker = $this->app->make(Invoker::class); + } + + #[Test] + public function it_can_resolve_a_timber_post_query(): void + { + $wpQuery = $this->mockWpQuery(); + $this->app->bind(WP_Query::class, $wpQuery); + + $controller = new class { + public function handle(PostQuery $query) + { + return $query; + } + }; + + $result = $this->invoker->call([$controller, 'handle']); + + $this->assertInstanceOf(PostQuery::class, $result); + } + + #[Test] + public function it_can_resolve_a_post_collection_interface(): void + { + $wpQuery = $this->mockWpQuery(); + $this->app->bind(WP_Query::class, $wpQuery); + + $controller = new class { + public function handle(PostCollectionInterface $query) + { + return $query; + } + }; + + $result = $this->invoker->call([$controller, 'handle']); + + $this->assertInstanceOf(PostCollectionInterface::class, $result); + } + + #[Test] + public function it_can_resolve_a_subclass_of_timber_post_query(): void + { + $wpQuery = $this->mockWpQuery(); + $this->app->bind(WP_Query::class, $wpQuery); + + $controller = new class { + public function handle(PostQueryStub $query) + { + return $query; + } + }; + + $result = $this->invoker->call([$controller, 'handle']); + + $this->assertInstanceOf(PostQueryStub::class, $result); + } + + protected function mockWpQuery() + { + Functions\expect('wp_parse_args')->andReturnUsing(fn($a, $b) => array_merge($b, $a)); + + $wpQuery = Mockery::mock(WP_Query::class); + $wpQuery->found_posts = 0; + $wpQuery->posts = []; + + return $wpQuery; + } +} diff --git a/tests/Unit/Http/Resolvers/PostResolverTest.php b/tests/Unit/Http/Resolvers/PostResolverTest.php new file mode 100644 index 0000000..544df78 --- /dev/null +++ b/tests/Unit/Http/Resolvers/PostResolverTest.php @@ -0,0 +1,89 @@ +app = new Application(__DIR__ . '/../../../../'); + + $config = Mockery::mock(Config::class); + $config->shouldReceive('get')->with('app.resolvers', [])->andReturn([]); + $this->app->bind('config', $config); + + $provider = new WordPressControllersServiceProvider($this->app); + $provider->register(); + + $this->invoker = $this->app->make(Invoker::class); + } + + #[Test] + public function it_can_resolve_a_timber_post(): void + { + $wpPost = Mockery::mock(WP_Post::class); + Functions\expect('get_queried_object')->once()->andReturn($wpPost); + + $timberPost = Mockery::mock(Post::class); + $timber = Mockery::mock('alias:' . Timber::class); + $timber->shouldReceive('get_post')->once()->with($wpPost)->andReturn($timberPost); + + $controller = new class { + public function handle(Post $post) + { + return $post; + } + }; + + $result = $this->invoker->call([$controller, 'handle']); + + $this->assertSame($timberPost, $result); + } + + #[Test] + public function it_can_resolve_a_subclass_of_timber_post(): void + { + $wpPost = Mockery::mock(WP_Post::class); + Functions\expect('get_queried_object')->once()->andReturn($wpPost); + + $postStub = Mockery::mock(PostStub::class); + $timber = Mockery::mock('alias:' . Timber::class); + $timber->shouldReceive('get_post')->once()->with($wpPost)->andReturn($postStub); + + $controller = new class { + public function handle(PostStub $post) + { + return $post; + } + }; + + $result = $this->invoker->call([$controller, 'handle']); + + $this->assertSame($postStub, $result); + } +} diff --git a/tests/Unit/Http/Resolvers/Stubs/PostQueryStub.php b/tests/Unit/Http/Resolvers/Stubs/PostQueryStub.php new file mode 100644 index 0000000..2c0cca1 --- /dev/null +++ b/tests/Unit/Http/Resolvers/Stubs/PostQueryStub.php @@ -0,0 +1,9 @@ +app = new Application(__DIR__ . '/../../../../'); + + $config = Mockery::mock(Config::class); + $config->shouldReceive('get')->with('app.resolvers', [])->andReturn([]); + $this->app->bind('config', $config); + + $provider = new WordPressControllersServiceProvider($this->app); + $provider->register(); + + $this->invoker = $this->app->make(Invoker::class); + } + + #[Test] + public function it_can_resolve_a_timber_term(): void + { + $wpTerm = Mockery::mock(WP_Term::class); + Functions\expect('get_queried_object')->once()->andReturn($wpTerm); + + $timberTerm = Mockery::mock(Term::class); + $timber = Mockery::mock('alias:' . Timber::class); + $timber->shouldReceive('get_term')->once()->with($wpTerm)->andReturn($timberTerm); + + $controller = new class { + public function handle(Term $term) + { + return $term; + } + }; + + $result = $this->invoker->call([$controller, 'handle']); + + $this->assertSame($timberTerm, $result); + } + + #[Test] + public function it_can_resolve_a_subclass_of_timber_term(): void + { + $wpTerm = Mockery::mock(WP_Term::class); + Functions\expect('get_queried_object')->once()->andReturn($wpTerm); + + $termStub = Mockery::mock(TermStub::class); + $timber = Mockery::mock('alias:' . Timber::class); + $timber->shouldReceive('get_term')->once()->with($wpTerm)->andReturn($termStub); + + $controller = new class { + public function handle(TermStub $term) + { + return $term; + } + }; + + $result = $this->invoker->call([$controller, 'handle']); + + $this->assertSame($termStub, $result); + } +} diff --git a/tests/Unit/Http/Resolvers/UserResolverTest.php b/tests/Unit/Http/Resolvers/UserResolverTest.php new file mode 100644 index 0000000..11414fc --- /dev/null +++ b/tests/Unit/Http/Resolvers/UserResolverTest.php @@ -0,0 +1,89 @@ +app = new Application(__DIR__ . '/../../../../'); + + $config = Mockery::mock(Config::class); + $config->shouldReceive('get')->with('app.resolvers', [])->andReturn([]); + $this->app->bind('config', $config); + + $provider = new WordPressControllersServiceProvider($this->app); + $provider->register(); + + $this->invoker = $this->app->make(Invoker::class); + } + + #[Test] + public function it_can_resolve_a_timber_user(): void + { + $wpUser = Mockery::mock(WP_User::class); + Functions\expect('get_queried_object')->once()->andReturn($wpUser); + + $timberUser = Mockery::mock(User::class); + $timber = Mockery::mock('alias:' . Timber::class); + $timber->shouldReceive('get_user')->once()->with($wpUser)->andReturn($timberUser); + + $controller = new class { + public function handle(User $user) + { + return $user; + } + }; + + $result = $this->invoker->call([$controller, 'handle']); + + $this->assertSame($timberUser, $result); + } + + #[Test] + public function it_can_resolve_a_subclass_of_timber_user(): void + { + $wpUser = Mockery::mock(WP_User::class); + Functions\expect('get_queried_object')->once()->andReturn($wpUser); + + $userStub = Mockery::mock(UserStub::class); + $timber = Mockery::mock('alias:' . Timber::class); + $timber->shouldReceive('get_user')->once()->with($wpUser)->andReturn($userStub); + + $controller = new class { + public function handle(UserStub $user) + { + return $user; + } + }; + + $result = $this->invoker->call([$controller, 'handle']); + + $this->assertSame($userStub, $result); + } +} diff --git a/tests/Unit/Http/Responses/TimberResponseTest.php b/tests/Unit/Http/Responses/TimberResponseTest.php index 6ebbeff..fc27804 100644 --- a/tests/Unit/Http/Responses/TimberResponseTest.php +++ b/tests/Unit/Http/Responses/TimberResponseTest.php @@ -109,6 +109,9 @@ public function contexts_with_view_models_are_converted(): void ->andReturn('testing123'); $response = new TimberResponse('template.twig', $context, 123); + + $this->assertSame(123, $response->getStatusCode()); + $this->assertSame('testing123', $response->getBody()->__toString()); } #[Test] @@ -135,6 +138,9 @@ public function contexts_with_view_models_at_lower_levels_of_nesting_are_convert ->andReturn('testing123'); $response = new TimberResponse('template.twig', $context, 123); + + $this->assertSame(123, $response->getStatusCode()); + $this->assertSame('testing123', $response->getBody()->__toString()); } #[Test] @@ -177,6 +183,9 @@ public function contexts_with_collections_are_converted(): void ->andReturn('testing123'); $response = new TimberResponse('template.twig', $context, 123); + + $this->assertSame(123, $response->getStatusCode()); + $this->assertSame('testing123', $response->getBody()->__toString()); } #[Test] @@ -203,6 +212,33 @@ public function contexts_with_collections_at_lower_levels_of_nesting_are_convert ->andReturn('testing123'); $response = new TimberResponse('template.twig', $context, 123); + + $this->assertSame(123, $response->getStatusCode()); + $this->assertSame('testing123', $response->getBody()->__toString()); + } + + #[Test] + public function contexts_that_are_arrayable_objects_are_converted(): void + { + $context = collect([ + 'foo' => 'bar', + ]); + + $timber = Mockery::mock('alias:' . Timber::class); + $timber->shouldReceive('compile') + ->with('template.twig', Mockery::on(function ($passedContext) { + $this->assertIsArray($passedContext); + $this->assertSame('bar', $passedContext['foo']); + + return true; + })) + ->once() + ->andReturn('testing123'); + + $response = new TimberResponse('template.twig', $context, 123); + + $this->assertSame(123, $response->getStatusCode()); + $this->assertSame('testing123', $response->getBody()->__toString()); } #[Test] @@ -228,6 +264,9 @@ public function contexts_with_view_models_in_collections_are_converted(): void ->andReturn('testing123'); $response = new TimberResponse('template.twig', $context, 123); + + $this->assertSame(123, $response->getStatusCode()); + $this->assertSame('testing123', $response->getBody()->__toString()); } } diff --git a/tests/Unit/Http/TimberContextTest.php b/tests/Unit/Http/TimberContextTest.php new file mode 100644 index 0000000..5462221 --- /dev/null +++ b/tests/Unit/Http/TimberContextTest.php @@ -0,0 +1,56 @@ +set('foo.bar', 'baz'); + + $this->assertSame('baz', $context->get('foo.bar')); + $this->assertSame(['bar' => 'baz'], $context->get('foo')); + } + + #[Test] + public function can_check_if_key_exists_using_dot_notation(): void + { + $context = new TimberContext(['foo' => ['bar' => 'baz']]); + + $this->assertTrue($context->has('foo.bar')); + $this->assertFalse($context->has('foo.qux')); + } + + #[Test] + public function get_returns_default_if_key_does_not_exist(): void + { + $context = new TimberContext(); + + $this->assertSame('default', $context->get('missing', 'default')); + } + + #[Test] + public function can_get_data_with_numeric_keys_using_dot_notation(): void + { + $context = new TimberContext([ + 'posts' => [ + ['title' => 'Post 1'], + ['title' => 'Post 2'], + ], + 'collection' => collect([ + ['title' => 'Post 3'], + ]), + ]); + + $this->assertSame('Post 1', $context->get('posts.0.title')); + $this->assertSame(['title' => 'Post 2'], $context->get('posts.1')); + $this->assertSame('Post 3', $context->get('collection.0.title')); + } +} diff --git a/tests/Unit/PostQueryTest.php b/tests/Unit/PostQueryTest.php new file mode 100644 index 0000000..96b7e93 --- /dev/null +++ b/tests/Unit/PostQueryTest.php @@ -0,0 +1,44 @@ +found_posts = 0; + $wpQuery->posts = []; + + $postQuery = new PostQuery($wpQuery); + + $this->assertInstanceOf(TimberPostQuery::class, $postQuery); + } + + #[Test] + public function can_extend_post_query_with_macros(): void + { + PostQuery::macro('testMacro', function () { + return 'macro_result'; + }); + + $wpQuery = Mockery::mock(WP_Query::class); + $wpQuery->found_posts = 0; + $wpQuery->posts = []; + + $postQuery = new PostQuery($wpQuery); + + $this->assertSame('macro_result', $postQuery->testMacro()); + } +} diff --git a/tests/Unit/PostTest.php b/tests/Unit/PostTest.php index 83aa502..1c42fa7 100644 --- a/tests/Unit/PostTest.php +++ b/tests/Unit/PostTest.php @@ -2,6 +2,7 @@ namespace Rareloop\Lumberjack\Test; +use Brain\Monkey\Filters; use Brain\Monkey\Functions; use Illuminate\Support\Collection; use Mockery; @@ -10,6 +11,8 @@ use PHPUnit\Framework\Attributes\Test; use Rareloop\Lumberjack\Test\TestCase; use Rareloop\Lumberjack\Post; +use Rareloop\Lumberjack\Page; +use Rareloop\Lumberjack\Exceptions\PostTypeRegistrationException; use Rareloop\Lumberjack\Test\Unit\Concerns\BrainMonkeyPHPUnitIntegration; use Timber\Post as TimberPost; use Timber\Timber; @@ -40,14 +43,14 @@ public function register_function_calls_register_post_type_when_post_type_and_co #[Test] public function register_function_throws_exception_if_post_type_is_not_provided() { - $this->expectException(\Rareloop\Lumberjack\Exceptions\PostTypeRegistrationException::class); + $this->expectException(PostTypeRegistrationException::class); UnregisterablePostTypeWithoutPostType::register(); } #[Test] public function register_function_throws_exception_if_config_is_not_provided() { - $this->expectException(\Rareloop\Lumberjack\Exceptions\PostTypeRegistrationException::class); + $this->expectException(PostTypeRegistrationException::class); UnregisterablePostTypeWithoutConfig::register(); } diff --git a/tests/Unit/Providers/TimberServiceProviderTest.php b/tests/Unit/Providers/TimberServiceProviderTest.php index 553b02c..4c27e81 100644 --- a/tests/Unit/Providers/TimberServiceProviderTest.php +++ b/tests/Unit/Providers/TimberServiceProviderTest.php @@ -17,6 +17,8 @@ use Rareloop\Lumberjack\Test\Unit\Concerns\BrainMonkeyPHPUnitIntegration; use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses; use PHPUnit\Framework\Attributes\PreserveGlobalState; +use Rareloop\Lumberjack\Http\TimberContext; +use Rareloop\Lumberjack\Http\Responses\TimberResponse; #[RunTestsInSeparateProcesses] #[PreserveGlobalState(false)] @@ -24,6 +26,39 @@ class TimberServiceProviderTest extends TestCase { use BrainMonkeyPHPUnitIntegration; + #[Test] + public function it_registers_timber_context_as_a_singleton(): void + { + $timber = Mockery::mock('alias:' . Timber::class); + $timber->shouldReceive('init'); + $timber->shouldReceive('context')->once()->andReturn(['foo' => 'bar']); + + $app = new Application(); + $provider = new TimberServiceProvider($app); + $provider->register(); + + $this->assertTrue($app->has(TimberContext::class)); + $context = $app->get(TimberContext::class); + $this->assertInstanceOf(TimberContext::class, $context); + $this->assertSame('bar', $context->get('foo')); + + // Verify singleton + $this->assertSame($context, $app->get(TimberContext::class)); + } + + #[Test] + public function it_binds_timber_response(): void + { + $timber = Mockery::mock('alias:' . Timber::class); + $timber->shouldReceive('init'); + + $app = new Application(); + $provider = new TimberServiceProvider($app); + $provider->register(); + + $this->assertTrue($app->has(TimberResponse::class)); + } + #[Test] public function timber_plugin_is_initialiased(): void { diff --git a/tests/Unit/TermTest.php b/tests/Unit/TermTest.php new file mode 100644 index 0000000..dc2c5fe --- /dev/null +++ b/tests/Unit/TermTest.php @@ -0,0 +1,30 @@ +assertSame('macro_result', (new $termClass())->testMacro()); + } +} diff --git a/tests/Unit/UserTest.php b/tests/Unit/UserTest.php new file mode 100644 index 0000000..3974fba --- /dev/null +++ b/tests/Unit/UserTest.php @@ -0,0 +1,30 @@ +assertSame('macro_result', (new $userClass())->testMacro()); + } +}