From 784715d34398f21e29925bb0501ff34450fda61c Mon Sep 17 00:00:00 2001 From: Tom Mitchelmore Date: Mon, 18 May 2026 09:44:57 +0100 Subject: [PATCH 01/27] Implement native dependency injection for template controllers - Refactor parameter resolution into a SOLID, registry-based architecture using native PHP-DI chains - Add specialized context resolvers for Timber Post, Term, User, and PostType objects - Support nullable typehints while maintaining strict enforcement for type mismatches - Introduce Macroable proxies for Timber PostQuery, Term, User, and PostType to ensure ecosystem consistency - Bind TimberContext as a shared singleton Collection for cross-component data orchestration - Integrate view helpers and container-aware response instantiation into the base Controller --- src/Bootstrappers/RegisterRequestHandler.php | 4 ++ src/Exceptions/MismatchedContextException.php | 16 ++++++ src/Exceptions/MissingContextException.php | 16 ++++++ .../UnresolvableContextException.php | 9 +++ src/Helpers.php | 7 ++- src/Http/Controller.php | 15 +++++ .../Resolvers/AbstractContextResolver.php | 41 +++++++++++++ src/Http/Resolvers/PostQueryResolver.php | 38 +++++++++++++ src/Http/Resolvers/PostResolver.php | 43 ++++++++++++++ src/Http/Resolvers/PostTypeResolver.php | 57 +++++++++++++++++++ src/Http/Resolvers/TermResolver.php | 46 +++++++++++++++ src/Http/Resolvers/UserResolver.php | 46 +++++++++++++++ src/Http/Responses/TimberResponse.php | 11 ++-- src/Http/TimberContext.php | 51 +++++++++++++++++ src/Post.php | 11 ++++ src/PostQuery.php | 11 ++++ src/PostType.php | 21 +++++++ src/Providers/TimberServiceProvider.php | 6 ++ .../WordPressControllersServiceProvider.php | 23 +++++++- src/Term.php | 11 ++++ src/User.php | 11 ++++ 21 files changed, 485 insertions(+), 9 deletions(-) create mode 100644 src/Exceptions/MismatchedContextException.php create mode 100644 src/Exceptions/MissingContextException.php create mode 100644 src/Exceptions/UnresolvableContextException.php create mode 100644 src/Http/Resolvers/AbstractContextResolver.php create mode 100644 src/Http/Resolvers/PostQueryResolver.php create mode 100644 src/Http/Resolvers/PostResolver.php create mode 100644 src/Http/Resolvers/PostTypeResolver.php create mode 100644 src/Http/Resolvers/TermResolver.php create mode 100644 src/Http/Resolvers/UserResolver.php create mode 100644 src/Http/TimberContext.php create mode 100644 src/PostQuery.php create mode 100644 src/PostType.php create mode 100644 src/Term.php create mode 100644 src/User.php diff --git a/src/Bootstrappers/RegisterRequestHandler.php b/src/Bootstrappers/RegisterRequestHandler.php index c3d8f32c..64d14192 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 00000000..4db78e8c --- /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/Controller.php b/src/Http/Controller.php index 17befb41..3afd14f0 100644 --- a/src/Http/Controller.php +++ b/src/Http/Controller.php @@ -2,8 +2,23 @@ namespace Rareloop\Lumberjack\Http; +use Rareloop\Lumberjack\Helpers; +use Rareloop\Lumberjack\Http\Responses\TimberResponse; use Rareloop\Router\Controller as BaseController; class Controller extends BaseController { + /** + * Return a new TimberResponse from the controller. + * + * @param string $template + * @param array|\Illuminate\Contracts\Support\Arrayable $context + * @param integer $status + * @param array $headers + * @return \Rareloop\Lumberjack\Http\Responses\TimberResponse + */ + public function view(string $template, $context = [], int $status = 200, array $headers = []) + { + return Helpers::view($template, $context, $status, $headers); + } } diff --git a/src/Http/Resolvers/AbstractContextResolver.php b/src/Http/Resolvers/AbstractContextResolver.php new file mode 100644 index 00000000..1889c357 --- /dev/null +++ b/src/Http/Resolvers/AbstractContextResolver.php @@ -0,0 +1,41 @@ +getParameters()) + ->reject(fn($p) => Arr::has($resolvedParameters, $p->getPosition())) + ->filter(fn($p) => $this->canResolve($p)) + ->reduce(function ($resolved, $p) { + try { + $resolved[$p->getPosition()] = $this->resolve($p); + } catch (MissingContextException $e) { + // If the context is entirely missing, we allow null if the typehint supports it + if (!$p->allowsNull()) { + throw $e; + } + + $resolved[$p->getPosition()] = null; + } + + return $resolved; + }, $resolvedParameters); + } + + abstract protected function canResolve(ReflectionParameter $parameter): bool; + + abstract protected function resolve(ReflectionParameter $parameter): mixed; +} diff --git a/src/Http/Resolvers/PostQueryResolver.php b/src/Http/Resolvers/PostQueryResolver.php new file mode 100644 index 00000000..c1d65f29 --- /dev/null +++ b/src/Http/Resolvers/PostQueryResolver.php @@ -0,0 +1,38 @@ +getType(); + + if (!$type || $type->isBuiltin()) { + return false; + } + + $className = $type->getName(); + + return $className === TimberPostQuery::class || is_subclass_of($className, TimberPostQuery::class); + } + + protected function resolve(ReflectionParameter $parameter): mixed + { + $className = $parameter->getType()->getName(); + + // Resolve WP_Query from the container instead of globals + $query = $this->app->get(WP_Query::class); + + return new $className($query); + } +} diff --git a/src/Http/Resolvers/PostResolver.php b/src/Http/Resolvers/PostResolver.php new file mode 100644 index 00000000..c14108f6 --- /dev/null +++ b/src/Http/Resolvers/PostResolver.php @@ -0,0 +1,43 @@ +getType(); + + if (!$type || $type->isBuiltin()) { + return false; + } + + $className = $type->getName(); + + return $className === Post::class || is_subclass_of($className, Post::class); + } + + protected function resolve(ReflectionParameter $parameter): mixed + { + $className = $parameter->getType()->getName(); + $queriedObject = get_queried_object(); + + if (!$queriedObject instanceof \WP_Post) { + throw MissingContextException::forType($className, $queriedObject); + } + + $post = Timber::get_post($queriedObject); + + if (!$post instanceof $className) { + throw MismatchedContextException::forIncorrectClass($className, $post); + } + + return $post; + } +} diff --git a/src/Http/Resolvers/PostTypeResolver.php b/src/Http/Resolvers/PostTypeResolver.php new file mode 100644 index 00000000..5acb013a --- /dev/null +++ b/src/Http/Resolvers/PostTypeResolver.php @@ -0,0 +1,57 @@ +getType(); + + if (!$type || $type->isBuiltin()) { + return false; + } + + $className = $type->getName(); + + return $className === PostType::class || $className === TimberPostType::class; + } + + protected function resolve(ReflectionParameter $parameter): mixed + { + $className = $parameter->getType()->getName(); + $queriedObject = get_queried_object(); + + $postType = null; + + if ($queriedObject instanceof WP_Post_Type) { + $postType = new PostType($queriedObject->name); + } + + if ($queriedObject instanceof WP_Post) { + $postTypeObject = get_post_type_object($queriedObject->post_type); + + if ($postTypeObject) { + $postType = new PostType($postTypeObject->name); + } + } + + if (!$postType) { + throw MissingContextException::forType($className, $queriedObject); + } + + if (!$postType instanceof $className) { + throw MismatchedContextException::forIncorrectClass($className, $postType); + } + + return $postType; + } +} diff --git a/src/Http/Resolvers/TermResolver.php b/src/Http/Resolvers/TermResolver.php new file mode 100644 index 00000000..39b30e85 --- /dev/null +++ b/src/Http/Resolvers/TermResolver.php @@ -0,0 +1,46 @@ +getType(); + + if (!$type || $type->isBuiltin()) { + return false; + } + + $className = $type->getName(); + + return $className === Term::class + || is_subclass_of($className, Term::class) + || $className === TimberTerm::class; + } + + protected function resolve(ReflectionParameter $parameter): mixed + { + $className = $parameter->getType()->getName(); + $queriedObject = get_queried_object(); + + if (!$queriedObject instanceof \WP_Term) { + throw MissingContextException::forType($className, $queriedObject); + } + + $term = Timber::get_term($queriedObject); + + if (!$term instanceof $className) { + throw MismatchedContextException::forIncorrectClass($className, $term); + } + + return $term; + } +} diff --git a/src/Http/Resolvers/UserResolver.php b/src/Http/Resolvers/UserResolver.php new file mode 100644 index 00000000..b108a237 --- /dev/null +++ b/src/Http/Resolvers/UserResolver.php @@ -0,0 +1,46 @@ +getType(); + + if (!$type || $type->isBuiltin()) { + return false; + } + + $className = $type->getName(); + + return $className === User::class + || is_subclass_of($className, User::class) + || $className === TimberUser::class; + } + + protected function resolve(ReflectionParameter $parameter): mixed + { + $className = $parameter->getType()->getName(); + $queriedObject = get_queried_object(); + + if (!$queriedObject instanceof \WP_User) { + throw MissingContextException::forType($className, $queriedObject); + } + + $user = Timber::get_user($queriedObject); + + if (!$user instanceof $className) { + throw MismatchedContextException::forIncorrectClass($className, $user); + } + + return $user; + } +} diff --git a/src/Http/Responses/TimberResponse.php b/src/Http/Responses/TimberResponse.php index 8ac161e6..ff13f7ef 100644 --- a/src/Http/Responses/TimberResponse.php +++ b/src/Http/Responses/TimberResponse.php @@ -21,14 +21,13 @@ public function __construct($twigTemplate, $context, $status = 200, array $heade parent::__construct($template, $status, $headers); } - private function flattenContextToArrays(array $context): array + private function flattenContextToArrays(array|Arrayable|CollectionArrayable $context): array { - // 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) { + $context = is_array($context) ? $context : $context->toArray(); + + 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 00000000..ecd53bbe --- /dev/null +++ b/src/Http/TimberContext.php @@ -0,0 +1,51 @@ +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); + } +} diff --git a/src/Post.php b/src/Post.php index 376699bc..1f0492a9 100644 --- a/src/Post.php +++ b/src/Post.php @@ -112,6 +112,17 @@ public static function filterTimberPostClassMap(array $classMap): array return [...$classMap, static::getPostType() => static::class]; } + /** + * Get the Post class associated with a post type slug. + * + * @param string $postType + * @return string|null + */ + public static function postClass(string $postType): ?string + { + return Arr::get(apply_filters('timber/post/classmap', []), $postType); + } + /** * Get all posts of this type * diff --git a/src/PostQuery.php b/src/PostQuery.php new file mode 100644 index 00000000..ff9b9799 --- /dev/null +++ b/src/PostQuery.php @@ -0,0 +1,11 @@ +name); + } +} diff --git a/src/Providers/TimberServiceProvider.php b/src/Providers/TimberServiceProvider.php index 837aa639..2a1c269f 100644 --- a/src/Providers/TimberServiceProvider.php +++ b/src/Providers/TimberServiceProvider.php @@ -3,13 +3,19 @@ namespace Rareloop\Lumberjack\Providers; use Rareloop\Lumberjack\Config; +use Rareloop\Lumberjack\Http\TimberContext; use Timber\Timber; +use Rareloop\Lumberjack\Http\Responses\TimberResponse; class TimberServiceProvider extends ServiceProvider { public function register() { Timber::init(); + + $this->app->singleton(TimberContext::class, fn() => new TimberContext(Timber::context())); + + $this->app->bind(TimberResponse::class, TimberResponse::class); } public function boot(Config $config) diff --git a/src/Providers/WordPressControllersServiceProvider.php b/src/Providers/WordPressControllersServiceProvider.php index 49a206fb..f7ed99bf 100644 --- a/src/Providers/WordPressControllersServiceProvider.php +++ b/src/Providers/WordPressControllersServiceProvider.php @@ -11,9 +11,26 @@ use Rareloop\Lumberjack\Http\Middleware\PasswordProtected; use Laminas\Diactoros\ServerRequestFactory; use Rareloop\Router\ProvidesControllerMiddleware; +use Invoker\ParameterResolver\ResolverChain; +use Rareloop\Lumberjack\Http\Resolvers\PostQueryResolver; +use Rareloop\Lumberjack\Http\Resolvers\UserResolver; +use Rareloop\Lumberjack\Http\Resolvers\PostTypeResolver; +use Invoker\ParameterResolver\ResolverChain; class WordPressControllersServiceProvider extends ServiceProvider { + public function register() + { + $this->app->bind(Invoker::class, fn($app) => new Invoker($app, new ResolverChain( + collect([ + PostQueryResolver::class, + PostResolver::class, + TermResolver::class, + UserResolver::class, + PostTypeResolver::class, + ])->map(fn($class) => $app->make($class))->all() + ))); + } public function boot() { add_filter('template_include', [$this, 'handleTemplateInclude']); @@ -87,8 +104,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); } ]; diff --git a/src/Term.php b/src/Term.php new file mode 100644 index 00000000..4856c738 --- /dev/null +++ b/src/Term.php @@ -0,0 +1,11 @@ + Date: Mon, 18 May 2026 15:39:50 +0100 Subject: [PATCH 02/27] Refine template controller DI and add comprehensive test suite - Update context resolvers to support Timber 2 canonical patterns and subclasses - Implement config-driven resolver registration in WordPressControllersServiceProvider - Ensure correct resolver priority (User > Core > Router) by prepending to the chain - Add Macroable proxy classes for PostQuery, PostType, Term, and User - Implement full PHPUnit test suite for all new DI features and proxy objects - Fix missing imports and static state leakage in existing tests --- src/Http/Resolvers/PostQueryResolver.php | 13 +- src/Http/Resolvers/PostTypeResolver.php | 33 +++-- src/Http/Resolvers/TermResolver.php | 3 +- src/Http/Resolvers/UserResolver.php | 3 +- src/Post.php | 1 + .../WordPressControllersServiceProvider.php | 29 ++-- .../RegisterRequestHandlerTest.php | 16 ++ tests/Unit/HelpersTest.php | 3 + tests/Unit/Http/ControllerTest.php | 35 +++++ .../Http/Resolvers/PostQueryResolverTest.php | 100 +++++++++++++ .../Unit/Http/Resolvers/PostResolverTest.php | 137 ++++++++++++++++++ .../Http/Resolvers/PostTypeResolverTest.php | 136 +++++++++++++++++ .../Http/Resolvers/Stubs/PostQueryStub.php | 7 + tests/Unit/Http/Resolvers/Stubs/PostStub.php | 7 + tests/Unit/Http/Resolvers/Stubs/TermStub.php | 7 + tests/Unit/Http/Resolvers/Stubs/UserStub.php | 7 + .../Unit/Http/Resolvers/TermResolverTest.php | 135 +++++++++++++++++ .../Unit/Http/Resolvers/UserResolverTest.php | 135 +++++++++++++++++ .../Http/Responses/TimberResponseTest.php | 21 +++ tests/Unit/Http/TimberContextTest.php | 38 +++++ tests/Unit/PostQueryTest.php | 44 ++++++ tests/Unit/PostTest.php | 16 ++ tests/Unit/PostTypeTest.php | 58 ++++++++ .../Providers/TimberServiceProviderTest.php | 36 +++++ tests/Unit/TermTest.php | 41 ++++++ tests/Unit/UserTest.php | 41 ++++++ 26 files changed, 1075 insertions(+), 27 deletions(-) create mode 100644 tests/Unit/Http/ControllerTest.php create mode 100644 tests/Unit/Http/Resolvers/PostQueryResolverTest.php create mode 100644 tests/Unit/Http/Resolvers/PostResolverTest.php create mode 100644 tests/Unit/Http/Resolvers/PostTypeResolverTest.php create mode 100644 tests/Unit/Http/Resolvers/Stubs/PostQueryStub.php create mode 100644 tests/Unit/Http/Resolvers/Stubs/PostStub.php create mode 100644 tests/Unit/Http/Resolvers/Stubs/TermStub.php create mode 100644 tests/Unit/Http/Resolvers/Stubs/UserStub.php create mode 100644 tests/Unit/Http/Resolvers/TermResolverTest.php create mode 100644 tests/Unit/Http/Resolvers/UserResolverTest.php create mode 100644 tests/Unit/Http/TimberContextTest.php create mode 100644 tests/Unit/PostQueryTest.php create mode 100644 tests/Unit/PostTypeTest.php create mode 100644 tests/Unit/TermTest.php create mode 100644 tests/Unit/UserTest.php diff --git a/src/Http/Resolvers/PostQueryResolver.php b/src/Http/Resolvers/PostQueryResolver.php index c1d65f29..b609c27a 100644 --- a/src/Http/Resolvers/PostQueryResolver.php +++ b/src/Http/Resolvers/PostQueryResolver.php @@ -5,6 +5,8 @@ use Rareloop\Lumberjack\Application; use ReflectionParameter; use Timber\PostQuery as TimberPostQuery; +use Timber\PostCollectionInterface; +use Timber\Timber; use WP_Query; class PostQueryResolver extends AbstractContextResolver @@ -23,7 +25,9 @@ protected function canResolve(ReflectionParameter $parameter): bool $className = $type->getName(); - return $className === TimberPostQuery::class || is_subclass_of($className, TimberPostQuery::class); + return $className === TimberPostQuery::class + || $className === PostCollectionInterface::class + || is_subclass_of($className, TimberPostQuery::class); } protected function resolve(ReflectionParameter $parameter): mixed @@ -33,6 +37,13 @@ protected function resolve(ReflectionParameter $parameter): mixed // Resolve WP_Query from the container instead of globals $query = $this->app->get(WP_Query::class); + // 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($query); + } + + // 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($query); } } diff --git a/src/Http/Resolvers/PostTypeResolver.php b/src/Http/Resolvers/PostTypeResolver.php index 5acb013a..c7d73073 100644 --- a/src/Http/Resolvers/PostTypeResolver.php +++ b/src/Http/Resolvers/PostTypeResolver.php @@ -22,7 +22,7 @@ protected function canResolve(ReflectionParameter $parameter): bool $className = $type->getName(); - return $className === PostType::class || $className === TimberPostType::class; + return is_a($className, PostType::class, true) || is_a($className, TimberPostType::class, true); } protected function resolve(ReflectionParameter $parameter): mixed @@ -30,19 +30,7 @@ protected function resolve(ReflectionParameter $parameter): mixed $className = $parameter->getType()->getName(); $queriedObject = get_queried_object(); - $postType = null; - - if ($queriedObject instanceof WP_Post_Type) { - $postType = new PostType($queriedObject->name); - } - - if ($queriedObject instanceof WP_Post) { - $postTypeObject = get_post_type_object($queriedObject->post_type); - - if ($postTypeObject) { - $postType = new PostType($postTypeObject->name); - } - } + $postType = $this->getPostTypeFromQueriedObject($queriedObject); if (!$postType) { throw MissingContextException::forType($className, $queriedObject); @@ -54,4 +42,21 @@ protected function resolve(ReflectionParameter $parameter): mixed return $postType; } + + private function getPostTypeFromQueriedObject($queriedObject): ?PostType + { + if (is_a($queriedObject, 'WP_Post_Type')) { + return new PostType($queriedObject->name); + } + + if (is_a($queriedObject, 'WP_Post')) { + $postTypeObject = get_post_type_object($queriedObject->post_type); + + if ($postTypeObject) { + return new PostType($postTypeObject->name); + } + } + + return null; + } } diff --git a/src/Http/Resolvers/TermResolver.php b/src/Http/Resolvers/TermResolver.php index 39b30e85..cd35e4d4 100644 --- a/src/Http/Resolvers/TermResolver.php +++ b/src/Http/Resolvers/TermResolver.php @@ -23,7 +23,8 @@ protected function canResolve(ReflectionParameter $parameter): bool return $className === Term::class || is_subclass_of($className, Term::class) - || $className === TimberTerm::class; + || $className === TimberTerm::class + || is_subclass_of($className, TimberTerm::class); } protected function resolve(ReflectionParameter $parameter): mixed diff --git a/src/Http/Resolvers/UserResolver.php b/src/Http/Resolvers/UserResolver.php index b108a237..2ffe1dcf 100644 --- a/src/Http/Resolvers/UserResolver.php +++ b/src/Http/Resolvers/UserResolver.php @@ -23,7 +23,8 @@ protected function canResolve(ReflectionParameter $parameter): bool return $className === User::class || is_subclass_of($className, User::class) - || $className === TimberUser::class; + || $className === TimberUser::class + || is_subclass_of($className, TimberUser::class); } protected function resolve(ReflectionParameter $parameter): mixed diff --git a/src/Post.php b/src/Post.php index 1f0492a9..28d57c8b 100644 --- a/src/Post.php +++ b/src/Post.php @@ -7,6 +7,7 @@ use Spatie\Macroable\Macroable; use Timber\Post as TimberPost; use Timber\Timber; +use Illuminate\Support\Arr; class Post extends TimberPost { diff --git a/src/Providers/WordPressControllersServiceProvider.php b/src/Providers/WordPressControllersServiceProvider.php index f7ed99bf..5aeeccfa 100644 --- a/src/Providers/WordPressControllersServiceProvider.php +++ b/src/Providers/WordPressControllersServiceProvider.php @@ -15,21 +15,30 @@ use Rareloop\Lumberjack\Http\Resolvers\PostQueryResolver; use Rareloop\Lumberjack\Http\Resolvers\UserResolver; use Rareloop\Lumberjack\Http\Resolvers\PostTypeResolver; -use Invoker\ParameterResolver\ResolverChain; +use Rareloop\Lumberjack\Http\Resolvers\PostResolver; +use Rareloop\Lumberjack\Http\Resolvers\TermResolver; class WordPressControllersServiceProvider extends ServiceProvider { public function register() { - $this->app->bind(Invoker::class, fn($app) => new Invoker($app, new ResolverChain( - collect([ - PostQueryResolver::class, - PostResolver::class, - TermResolver::class, - UserResolver::class, - PostTypeResolver::class, - ])->map(fn($class) => $app->make($class))->all() - ))); + $this->app->bind(Invoker::class, function ($app) { + $invoker = new Invoker($app); + $resolverChain = $invoker->getParameterResolver(); + + collect($app->get('config')->get('app.resolvers', [])) + ->merge([ + PostTypeResolver::class, + PostQueryResolver::class, + PostResolver::class, + TermResolver::class, + UserResolver::class, + ]) + ->reverse() + ->each(fn($resolver) => $resolverChain->prependResolver($app->make($resolver))); + + return $invoker; + }); } public function boot() { diff --git a/tests/Unit/Bootstrappers/RegisterRequestHandlerTest.php b/tests/Unit/Bootstrappers/RegisterRequestHandlerTest.php index 6f1f5e7e..38128228 100644 --- a/tests/Unit/Bootstrappers/RegisterRequestHandlerTest.php +++ b/tests/Unit/Bootstrappers/RegisterRequestHandlerTest.php @@ -40,4 +40,20 @@ public function does_not_call_function_on_app_when_not_in_debug_mode() $bootstrapper = new RegisterRequestHandler(); $bootstrapper->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 c91fb11b..9f226895 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/ControllerTest.php b/tests/Unit/Http/ControllerTest.php new file mode 100644 index 00000000..71373d64 --- /dev/null +++ b/tests/Unit/Http/ControllerTest.php @@ -0,0 +1,35 @@ +shouldReceive('compile')->once()->andReturn('testing123'); + + $controller = new Controller(); + $response = $controller->view('template.twig', ['foo' => 'bar'], 201, ['X-Header' => 'value']); + + $this->assertInstanceOf(TimberResponse::class, $response); + $this->assertSame(201, $response->getStatusCode()); + $this->assertSame('value', $response->getHeader('X-Header')[0]); + } +} diff --git a/tests/Unit/Http/Resolvers/PostQueryResolverTest.php b/tests/Unit/Http/Resolvers/PostQueryResolverTest.php new file mode 100644 index 00000000..c88ffe6c --- /dev/null +++ b/tests/Unit/Http/Resolvers/PostQueryResolverTest.php @@ -0,0 +1,100 @@ +app = new Application(__DIR__ . '/../../../../'); + + $config = Mockery::mock(\Rareloop\Lumberjack\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 + { + 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 = []; + $this->app->bind(WP_Query::class, $wpQuery); + + $controller = new class { + public function handle(\Timber\PostQuery $query) { + return $query; + } + }; + + $result = $this->invoker->call([$controller, 'handle']); + + $this->assertInstanceOf(\Timber\PostQuery::class, $result); + } + + #[Test] + public function it_can_resolve_a_post_collection_interface(): void + { + 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 = []; + $this->app->bind(WP_Query::class, $wpQuery); + + $controller = new class { + public function handle(\Timber\PostCollectionInterface $query) { + return $query; + } + }; + + $result = $this->invoker->call([$controller, 'handle']); + + $this->assertInstanceOf(\Timber\PostCollectionInterface::class, $result); + } + + #[Test] + public function it_can_resolve_a_subclass_of_timber_post_query(): void + { + $wpQuery = Mockery::mock(WP_Query::class); + $wpQuery->found_posts = 0; + $wpQuery->posts = []; + $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); + } +} diff --git a/tests/Unit/Http/Resolvers/PostResolverTest.php b/tests/Unit/Http/Resolvers/PostResolverTest.php new file mode 100644 index 00000000..8ca4f0ad --- /dev/null +++ b/tests/Unit/Http/Resolvers/PostResolverTest.php @@ -0,0 +1,137 @@ +app = new Application(__DIR__ . '/../../../../'); + + $config = Mockery::mock(\Rareloop\Lumberjack\Config::class); + $config->shouldReceive('get')->with('app.resolvers', [])->andReturn([]); + $this->app->bind('config', $config); + + // Register the provider so the Invoker is set up with all resolvers + $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); + } + + #[Test] + public function it_throws_an_exception_if_the_queried_object_is_not_a_post(): void + { + Functions\expect('get_queried_object')->once()->andReturn(null); + + $controller = new class { + public function handle(Post $post) {} + }; + + $this->expectException(\Rareloop\Lumberjack\Exceptions\MissingContextException::class); + + $this->invoker->call([$controller, 'handle']); + } + + #[Test] + public function it_resolves_to_null_if_the_typehint_is_nullable_and_context_is_missing(): void + { + Functions\expect('get_queried_object')->once()->andReturn(null); + + $controller = new class { + public function handle(?Post $post) { + return $post; + } + }; + + $result = $this->invoker->call([$controller, 'handle']); + + $this->assertNull($result); + } + + #[Test] + public function it_throws_an_exception_if_the_resolved_post_is_not_of_the_expected_typehinted_class(): void + { + $wpPost = Mockery::mock(WP_Post::class); + Functions\expect('get_queried_object')->once()->andReturn($wpPost); + + $timberPost = Mockery::mock(Post::class); // Not a PostStub + $timber = Mockery::mock('alias:' . Timber::class); + $timber->shouldReceive('get_post')->once()->with($wpPost)->andReturn($timberPost); + + $controller = new class { + public function handle(PostStub $post) {} + }; + + $this->expectException(\Rareloop\Lumberjack\Exceptions\MismatchedContextException::class); + + $this->invoker->call([$controller, 'handle']); + } +} diff --git a/tests/Unit/Http/Resolvers/PostTypeResolverTest.php b/tests/Unit/Http/Resolvers/PostTypeResolverTest.php new file mode 100644 index 00000000..302f157d --- /dev/null +++ b/tests/Unit/Http/Resolvers/PostTypeResolverTest.php @@ -0,0 +1,136 @@ +app = new Application(__DIR__ . '/../../../../'); + + $config = Mockery::mock(\Rareloop\Lumberjack\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_post_type_from_a_post_type_object(): void + { + $wpPostType = Mockery::mock('WP_Post_Type'); + $wpPostType->name = 'page'; + Functions\expect('get_queried_object')->once()->andReturn($wpPostType); + + // Timber\PostType constructor calls get_post_type_object + Functions\expect('get_post_type_object')->once()->with('page')->andReturn($wpPostType); + + $controller = new class { + public function handle(LumberjackPostType $postType) { + return $postType; + } + }; + + $result = $this->invoker->call([$controller, 'handle']); + + $this->assertInstanceOf(LumberjackPostType::class, $result); + $this->assertSame('page', (string)$result); + } + + #[Test] + public function it_can_resolve_a_post_type_from_a_post_object(): void + { + $wpPost = Mockery::mock('WP_Post'); + $wpPost->post_type = 'post'; + Functions\expect('get_queried_object')->once()->andReturn($wpPost); + + $wpPostTypeObject = Mockery::mock('WP_Post_Type'); + $wpPostTypeObject->name = 'post'; + + // Called once by our resolver and once by the Timber\PostType constructor + Functions\expect('get_post_type_object')->twice()->with('post')->andReturn($wpPostTypeObject); + + $controller = new class { + public function handle(LumberjackPostType $postType) { + return $postType; + } + }; + + $result = $this->invoker->call([$controller, 'handle']); + + $this->assertInstanceOf(LumberjackPostType::class, $result); + $this->assertSame('post', (string)$result); + } + + #[Test] + public function it_can_resolve_a_timber_post_type(): void + { + $wpPostType = Mockery::mock('WP_Post_Type'); + $wpPostType->name = 'page'; + Functions\expect('get_queried_object')->once()->andReturn($wpPostType); + + Functions\expect('get_post_type_object')->once()->with('page')->andReturn($wpPostType); + + $controller = new class { + public function handle(TimberPostType $postType) { + return $postType; + } + }; + + $result = $this->invoker->call([$controller, 'handle']); + + $this->assertInstanceOf(TimberPostType::class, $result); + $this->assertSame('page', (string)$result); + } + + #[Test] + public function it_throws_an_exception_if_the_context_is_missing(): void + { + Functions\expect('get_queried_object')->once()->andReturn(null); + + $controller = new class { + public function handle(LumberjackPostType $postType) {} + }; + + $this->expectException(\Rareloop\Lumberjack\Exceptions\MissingContextException::class); + + $this->invoker->call([$controller, 'handle']); + } + + #[Test] + public function it_resolves_to_null_if_the_typehint_is_nullable_and_context_is_missing(): void + { + Functions\expect('get_queried_object')->once()->andReturn(null); + + $controller = new class { + public function handle(?LumberjackPostType $postType) { + return $postType; + } + }; + + $result = $this->invoker->call([$controller, 'handle']); + + $this->assertNull($result); + } +} diff --git a/tests/Unit/Http/Resolvers/Stubs/PostQueryStub.php b/tests/Unit/Http/Resolvers/Stubs/PostQueryStub.php new file mode 100644 index 00000000..4d853e1a --- /dev/null +++ b/tests/Unit/Http/Resolvers/Stubs/PostQueryStub.php @@ -0,0 +1,7 @@ +app = new Application(__DIR__ . '/../../../../'); + + $config = Mockery::mock(\Rareloop\Lumberjack\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); + } + + #[Test] + public function it_throws_an_exception_if_the_queried_object_is_not_a_term(): void + { + Functions\expect('get_queried_object')->once()->andReturn(null); + + $controller = new class { + public function handle(Term $term) {} + }; + + $this->expectException(\Rareloop\Lumberjack\Exceptions\MissingContextException::class); + + $this->invoker->call([$controller, 'handle']); + } + + #[Test] + public function it_resolves_to_null_if_the_typehint_is_nullable_and_context_is_missing(): void + { + Functions\expect('get_queried_object')->once()->andReturn(null); + + $controller = new class { + public function handle(?Term $term) { + return $term; + } + }; + + $result = $this->invoker->call([$controller, 'handle']); + + $this->assertNull($result); + } + + #[Test] + public function it_throws_an_exception_if_the_resolved_term_is_not_of_the_expected_typehinted_class(): void + { + $wpTerm = Mockery::mock(WP_Term::class); + Functions\expect('get_queried_object')->once()->andReturn($wpTerm); + + $timberTerm = Mockery::mock(Term::class); // Not a TermStub + $timber = Mockery::mock('alias:' . Timber::class); + $timber->shouldReceive('get_term')->once()->with($wpTerm)->andReturn($timberTerm); + + $controller = new class { + public function handle(TermStub $term) {} + }; + + $this->expectException(\Rareloop\Lumberjack\Exceptions\MismatchedContextException::class); + + $this->invoker->call([$controller, 'handle']); + } +} diff --git a/tests/Unit/Http/Resolvers/UserResolverTest.php b/tests/Unit/Http/Resolvers/UserResolverTest.php new file mode 100644 index 00000000..8629cd2a --- /dev/null +++ b/tests/Unit/Http/Resolvers/UserResolverTest.php @@ -0,0 +1,135 @@ +app = new Application(__DIR__ . '/../../../../'); + + $config = Mockery::mock(\Rareloop\Lumberjack\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); + } + + #[Test] + public function it_throws_an_exception_if_the_queried_object_is_not_a_user(): void + { + Functions\expect('get_queried_object')->once()->andReturn(null); + + $controller = new class { + public function handle(User $user) {} + }; + + $this->expectException(\Rareloop\Lumberjack\Exceptions\MissingContextException::class); + + $this->invoker->call([$controller, 'handle']); + } + + #[Test] + public function it_resolves_to_null_if_the_typehint_is_nullable_and_context_is_missing(): void + { + Functions\expect('get_queried_object')->once()->andReturn(null); + + $controller = new class { + public function handle(?User $user) { + return $user; + } + }; + + $result = $this->invoker->call([$controller, 'handle']); + + $this->assertNull($result); + } + + #[Test] + public function it_throws_an_exception_if_the_resolved_user_is_not_of_the_expected_typehinted_class(): void + { + $wpUser = Mockery::mock(WP_User::class); + Functions\expect('get_queried_object')->once()->andReturn($wpUser); + + $timberUser = Mockery::mock(User::class); // Not a UserStub + $timber = Mockery::mock('alias:' . Timber::class); + $timber->shouldReceive('get_user')->once()->with($wpUser)->andReturn($timberUser); + + $controller = new class { + public function handle(UserStub $user) {} + }; + + $this->expectException(\Rareloop\Lumberjack\Exceptions\MismatchedContextException::class); + + $this->invoker->call([$controller, 'handle']); + } +} diff --git a/tests/Unit/Http/Responses/TimberResponseTest.php b/tests/Unit/Http/Responses/TimberResponseTest.php index 6ebbeffc..e9ab2f4c 100644 --- a/tests/Unit/Http/Responses/TimberResponseTest.php +++ b/tests/Unit/Http/Responses/TimberResponseTest.php @@ -205,6 +205,27 @@ public function contexts_with_collections_at_lower_levels_of_nesting_are_convert $response = new TimberResponse('template.twig', $context, 123); } + #[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); + } + #[Test] public function contexts_with_view_models_in_collections_are_converted(): void { diff --git a/tests/Unit/Http/TimberContextTest.php b/tests/Unit/Http/TimberContextTest.php new file mode 100644 index 00000000..82709adb --- /dev/null +++ b/tests/Unit/Http/TimberContextTest.php @@ -0,0 +1,38 @@ +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')); + } +} diff --git a/tests/Unit/PostQueryTest.php b/tests/Unit/PostQueryTest.php new file mode 100644 index 00000000..1266f1ff --- /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 83aa5022..a142ef19 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; @@ -145,6 +146,21 @@ public function all_defaults_to_unlimited_ordered_by_menu_order_ascending() $this->assertInstanceOf(Collection::class, $posts); } + #[Test] + public function can_get_post_class_from_classmap(): void + { + Filters\expectApplied('timber/post/classmap') + ->times(3) + ->andReturn([ + 'post' => Post::class, + 'page' => \Rareloop\Lumberjack\Page::class, + ]); + + $this->assertSame(Post::class, Post::postClass('post')); + $this->assertSame(\Rareloop\Lumberjack\Page::class, Post::postClass('page')); + $this->assertNull(Post::postClass('missing')); + } + #[Test] public function all_can_have_post_limit_set() { diff --git a/tests/Unit/PostTypeTest.php b/tests/Unit/PostTypeTest.php new file mode 100644 index 00000000..07454a94 --- /dev/null +++ b/tests/Unit/PostTypeTest.php @@ -0,0 +1,58 @@ +andReturn(Mockery::mock('WP_Post_Type')); + $postType = new PostType('post'); + + $this->assertInstanceOf(TimberPostType::class, $postType); + } + + #[Test] + public function can_get_post_class_associated_with_post_type(): void + { + $wpPostType = new \stdClass(); + $wpPostType->name = 'book'; + Functions\expect('get_post_type_object')->andReturn($wpPostType); + + $postType = new PostType('book'); + + Filters\expectApplied('timber/post/classmap') + ->once() + ->andReturn(['book' => 'App\Post\Book']); + + $this->assertSame('App\Post\Book', $postType->postClass()); + } + + #[Test] + public function can_extend_post_type_with_macros(): void + { + $wpPostType = new \stdClass(); + $wpPostType->name = 'post'; + Functions\expect('get_post_type_object')->andReturn($wpPostType); + + PostType::macro('testMacro', function () { + return 'macro_result'; + }); + + $postType = new PostType('post'); + + $this->assertSame('macro_result', $postType->testMacro()); + } +} diff --git a/tests/Unit/Providers/TimberServiceProviderTest.php b/tests/Unit/Providers/TimberServiceProviderTest.php index 553b02c2..59bea4c0 100644 --- a/tests/Unit/Providers/TimberServiceProviderTest.php +++ b/tests/Unit/Providers/TimberServiceProviderTest.php @@ -18,12 +18,48 @@ use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses; use PHPUnit\Framework\Attributes\PreserveGlobalState; +use Rareloop\Lumberjack\Http\TimberContext; +use Rareloop\Lumberjack\Http\Responses\TimberResponse; + #[RunTestsInSeparateProcesses] #[PreserveGlobalState(false)] 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 00000000..ef393b43 --- /dev/null +++ b/tests/Unit/TermTest.php @@ -0,0 +1,41 @@ +assertInstanceOf(TimberTerm::class, $term); + $this->assertInstanceOf(Term::class, $term); + } + + #[Test] + public function can_extend_term_with_macros(): void + { + Term::macro('testMacro', function () { + return 'macro_result'; + }); + + $term = TestableTerm::create(); + + $this->assertSame('macro_result', $term->testMacro()); + } +} + +class TestableTerm extends Term +{ + public function __construct() {} + public static function create() { return new static(); } +} diff --git a/tests/Unit/UserTest.php b/tests/Unit/UserTest.php new file mode 100644 index 00000000..fd591f55 --- /dev/null +++ b/tests/Unit/UserTest.php @@ -0,0 +1,41 @@ +assertInstanceOf(TimberUser::class, $user); + $this->assertInstanceOf(User::class, $user); + } + + #[Test] + public function can_extend_user_with_macros(): void + { + User::macro('testMacro', function () { + return 'macro_result'; + }); + + $user = TestableUser::create(); + + $this->assertSame('macro_result', $user->testMacro()); + } +} + +class TestableUser extends User +{ + public function __construct() {} + public static function create() { return new static(); } +} From 659057ca467e5532fe5617b895fd6fdfeba91d84 Mon Sep 17 00:00:00 2001 From: Tom Mitchelmore Date: Mon, 18 May 2026 15:44:51 +0100 Subject: [PATCH 03/27] Apply phpcs fixes --- .../Http/Resolvers/PostQueryResolverTest.php | 9 +++++--- .../Unit/Http/Resolvers/PostResolverTest.php | 21 ++++++++++++------ .../Http/Resolvers/PostTypeResolverTest.php | 22 ++++++++++++------- .../Http/Resolvers/Stubs/PostQueryStub.php | 4 +++- tests/Unit/Http/Resolvers/Stubs/PostStub.php | 4 +++- tests/Unit/Http/Resolvers/Stubs/TermStub.php | 4 +++- tests/Unit/Http/Resolvers/Stubs/UserStub.php | 4 +++- .../Unit/Http/Resolvers/TermResolverTest.php | 17 +++++++++----- .../Unit/Http/Resolvers/UserResolverTest.php | 17 +++++++++----- tests/Unit/PostQueryTest.php | 2 +- tests/Unit/PostTypeTest.php | 4 ++-- .../Providers/TimberServiceProviderTest.php | 1 - tests/Unit/TermTest.php | 9 ++++++-- tests/Unit/UserTest.php | 9 ++++++-- 14 files changed, 87 insertions(+), 40 deletions(-) diff --git a/tests/Unit/Http/Resolvers/PostQueryResolverTest.php b/tests/Unit/Http/Resolvers/PostQueryResolverTest.php index c88ffe6c..7a309ba2 100644 --- a/tests/Unit/Http/Resolvers/PostQueryResolverTest.php +++ b/tests/Unit/Http/Resolvers/PostQueryResolverTest.php @@ -48,7 +48,8 @@ public function it_can_resolve_a_timber_post_query(): void $this->app->bind(WP_Query::class, $wpQuery); $controller = new class { - public function handle(\Timber\PostQuery $query) { + public function handle(\Timber\PostQuery $query) + { return $query; } }; @@ -69,7 +70,8 @@ public function it_can_resolve_a_post_collection_interface(): void $this->app->bind(WP_Query::class, $wpQuery); $controller = new class { - public function handle(\Timber\PostCollectionInterface $query) { + public function handle(\Timber\PostCollectionInterface $query) + { return $query; } }; @@ -88,7 +90,8 @@ public function it_can_resolve_a_subclass_of_timber_post_query(): void $this->app->bind(WP_Query::class, $wpQuery); $controller = new class { - public function handle(PostQueryStub $query) { + public function handle(PostQueryStub $query) + { return $query; } }; diff --git a/tests/Unit/Http/Resolvers/PostResolverTest.php b/tests/Unit/Http/Resolvers/PostResolverTest.php index 8ca4f0ad..38481eeb 100644 --- a/tests/Unit/Http/Resolvers/PostResolverTest.php +++ b/tests/Unit/Http/Resolvers/PostResolverTest.php @@ -32,11 +32,11 @@ protected function setUp(): void parent::setUp(); $this->app = new Application(__DIR__ . '/../../../../'); - + $config = Mockery::mock(\Rareloop\Lumberjack\Config::class); $config->shouldReceive('get')->with('app.resolvers', [])->andReturn([]); $this->app->bind('config', $config); - + // Register the provider so the Invoker is set up with all resolvers $provider = new WordPressControllersServiceProvider($this->app); $provider->register(); @@ -55,7 +55,8 @@ public function it_can_resolve_a_timber_post(): void $timber->shouldReceive('get_post')->once()->with($wpPost)->andReturn($timberPost); $controller = new class { - public function handle(Post $post) { + public function handle(Post $post) + { return $post; } }; @@ -76,7 +77,8 @@ public function it_can_resolve_a_subclass_of_timber_post(): void $timber->shouldReceive('get_post')->once()->with($wpPost)->andReturn($postStub); $controller = new class { - public function handle(PostStub $post) { + public function handle(PostStub $post) + { return $post; } }; @@ -92,7 +94,9 @@ public function it_throws_an_exception_if_the_queried_object_is_not_a_post(): vo Functions\expect('get_queried_object')->once()->andReturn(null); $controller = new class { - public function handle(Post $post) {} + public function handle(Post $post) + { + } }; $this->expectException(\Rareloop\Lumberjack\Exceptions\MissingContextException::class); @@ -106,7 +110,8 @@ public function it_resolves_to_null_if_the_typehint_is_nullable_and_context_is_m Functions\expect('get_queried_object')->once()->andReturn(null); $controller = new class { - public function handle(?Post $post) { + public function handle(?Post $post) + { return $post; } }; @@ -127,7 +132,9 @@ public function it_throws_an_exception_if_the_resolved_post_is_not_of_the_expect $timber->shouldReceive('get_post')->once()->with($wpPost)->andReturn($timberPost); $controller = new class { - public function handle(PostStub $post) {} + public function handle(PostStub $post) + { + } }; $this->expectException(\Rareloop\Lumberjack\Exceptions\MismatchedContextException::class); diff --git a/tests/Unit/Http/Resolvers/PostTypeResolverTest.php b/tests/Unit/Http/Resolvers/PostTypeResolverTest.php index 302f157d..608a714c 100644 --- a/tests/Unit/Http/Resolvers/PostTypeResolverTest.php +++ b/tests/Unit/Http/Resolvers/PostTypeResolverTest.php @@ -42,12 +42,13 @@ public function it_can_resolve_a_post_type_from_a_post_type_object(): void $wpPostType = Mockery::mock('WP_Post_Type'); $wpPostType->name = 'page'; Functions\expect('get_queried_object')->once()->andReturn($wpPostType); - + // Timber\PostType constructor calls get_post_type_object Functions\expect('get_post_type_object')->once()->with('page')->andReturn($wpPostType); $controller = new class { - public function handle(LumberjackPostType $postType) { + public function handle(LumberjackPostType $postType) + { return $postType; } }; @@ -67,12 +68,13 @@ public function it_can_resolve_a_post_type_from_a_post_object(): void $wpPostTypeObject = Mockery::mock('WP_Post_Type'); $wpPostTypeObject->name = 'post'; - + // Called once by our resolver and once by the Timber\PostType constructor Functions\expect('get_post_type_object')->twice()->with('post')->andReturn($wpPostTypeObject); $controller = new class { - public function handle(LumberjackPostType $postType) { + public function handle(LumberjackPostType $postType) + { return $postType; } }; @@ -89,11 +91,12 @@ public function it_can_resolve_a_timber_post_type(): void $wpPostType = Mockery::mock('WP_Post_Type'); $wpPostType->name = 'page'; Functions\expect('get_queried_object')->once()->andReturn($wpPostType); - + Functions\expect('get_post_type_object')->once()->with('page')->andReturn($wpPostType); $controller = new class { - public function handle(TimberPostType $postType) { + public function handle(TimberPostType $postType) + { return $postType; } }; @@ -110,7 +113,9 @@ public function it_throws_an_exception_if_the_context_is_missing(): void Functions\expect('get_queried_object')->once()->andReturn(null); $controller = new class { - public function handle(LumberjackPostType $postType) {} + public function handle(LumberjackPostType $postType) + { + } }; $this->expectException(\Rareloop\Lumberjack\Exceptions\MissingContextException::class); @@ -124,7 +129,8 @@ public function it_resolves_to_null_if_the_typehint_is_nullable_and_context_is_m Functions\expect('get_queried_object')->once()->andReturn(null); $controller = new class { - public function handle(?LumberjackPostType $postType) { + public function handle(?LumberjackPostType $postType) + { return $postType; } }; diff --git a/tests/Unit/Http/Resolvers/Stubs/PostQueryStub.php b/tests/Unit/Http/Resolvers/Stubs/PostQueryStub.php index 4d853e1a..2c0cca12 100644 --- a/tests/Unit/Http/Resolvers/Stubs/PostQueryStub.php +++ b/tests/Unit/Http/Resolvers/Stubs/PostQueryStub.php @@ -4,4 +4,6 @@ use Timber\PostQuery; -class PostQueryStub extends PostQuery {} +class PostQueryStub extends PostQuery +{ +} diff --git a/tests/Unit/Http/Resolvers/Stubs/PostStub.php b/tests/Unit/Http/Resolvers/Stubs/PostStub.php index f5713fbc..f69e4c74 100644 --- a/tests/Unit/Http/Resolvers/Stubs/PostStub.php +++ b/tests/Unit/Http/Resolvers/Stubs/PostStub.php @@ -4,4 +4,6 @@ use Timber\Post; -class PostStub extends Post {} +class PostStub extends Post +{ +} diff --git a/tests/Unit/Http/Resolvers/Stubs/TermStub.php b/tests/Unit/Http/Resolvers/Stubs/TermStub.php index 4d70ac4a..a193d102 100644 --- a/tests/Unit/Http/Resolvers/Stubs/TermStub.php +++ b/tests/Unit/Http/Resolvers/Stubs/TermStub.php @@ -4,4 +4,6 @@ use Timber\Term; -class TermStub extends Term {} +class TermStub extends Term +{ +} diff --git a/tests/Unit/Http/Resolvers/Stubs/UserStub.php b/tests/Unit/Http/Resolvers/Stubs/UserStub.php index becdf67a..c7c12441 100644 --- a/tests/Unit/Http/Resolvers/Stubs/UserStub.php +++ b/tests/Unit/Http/Resolvers/Stubs/UserStub.php @@ -4,4 +4,6 @@ use Timber\User; -class UserStub extends User {} +class UserStub extends User +{ +} diff --git a/tests/Unit/Http/Resolvers/TermResolverTest.php b/tests/Unit/Http/Resolvers/TermResolverTest.php index 759fd49f..54d8095b 100644 --- a/tests/Unit/Http/Resolvers/TermResolverTest.php +++ b/tests/Unit/Http/Resolvers/TermResolverTest.php @@ -53,7 +53,8 @@ public function it_can_resolve_a_timber_term(): void $timber->shouldReceive('get_term')->once()->with($wpTerm)->andReturn($timberTerm); $controller = new class { - public function handle(Term $term) { + public function handle(Term $term) + { return $term; } }; @@ -74,7 +75,8 @@ public function it_can_resolve_a_subclass_of_timber_term(): void $timber->shouldReceive('get_term')->once()->with($wpTerm)->andReturn($termStub); $controller = new class { - public function handle(TermStub $term) { + public function handle(TermStub $term) + { return $term; } }; @@ -90,7 +92,9 @@ public function it_throws_an_exception_if_the_queried_object_is_not_a_term(): vo Functions\expect('get_queried_object')->once()->andReturn(null); $controller = new class { - public function handle(Term $term) {} + public function handle(Term $term) + { + } }; $this->expectException(\Rareloop\Lumberjack\Exceptions\MissingContextException::class); @@ -104,7 +108,8 @@ public function it_resolves_to_null_if_the_typehint_is_nullable_and_context_is_m Functions\expect('get_queried_object')->once()->andReturn(null); $controller = new class { - public function handle(?Term $term) { + public function handle(?Term $term) + { return $term; } }; @@ -125,7 +130,9 @@ public function it_throws_an_exception_if_the_resolved_term_is_not_of_the_expect $timber->shouldReceive('get_term')->once()->with($wpTerm)->andReturn($timberTerm); $controller = new class { - public function handle(TermStub $term) {} + public function handle(TermStub $term) + { + } }; $this->expectException(\Rareloop\Lumberjack\Exceptions\MismatchedContextException::class); diff --git a/tests/Unit/Http/Resolvers/UserResolverTest.php b/tests/Unit/Http/Resolvers/UserResolverTest.php index 8629cd2a..81af0de3 100644 --- a/tests/Unit/Http/Resolvers/UserResolverTest.php +++ b/tests/Unit/Http/Resolvers/UserResolverTest.php @@ -53,7 +53,8 @@ public function it_can_resolve_a_timber_user(): void $timber->shouldReceive('get_user')->once()->with($wpUser)->andReturn($timberUser); $controller = new class { - public function handle(User $user) { + public function handle(User $user) + { return $user; } }; @@ -74,7 +75,8 @@ public function it_can_resolve_a_subclass_of_timber_user(): void $timber->shouldReceive('get_user')->once()->with($wpUser)->andReturn($userStub); $controller = new class { - public function handle(UserStub $user) { + public function handle(UserStub $user) + { return $user; } }; @@ -90,7 +92,9 @@ public function it_throws_an_exception_if_the_queried_object_is_not_a_user(): vo Functions\expect('get_queried_object')->once()->andReturn(null); $controller = new class { - public function handle(User $user) {} + public function handle(User $user) + { + } }; $this->expectException(\Rareloop\Lumberjack\Exceptions\MissingContextException::class); @@ -104,7 +108,8 @@ public function it_resolves_to_null_if_the_typehint_is_nullable_and_context_is_m Functions\expect('get_queried_object')->once()->andReturn(null); $controller = new class { - public function handle(?User $user) { + public function handle(?User $user) + { return $user; } }; @@ -125,7 +130,9 @@ public function it_throws_an_exception_if_the_resolved_user_is_not_of_the_expect $timber->shouldReceive('get_user')->once()->with($wpUser)->andReturn($timberUser); $controller = new class { - public function handle(UserStub $user) {} + public function handle(UserStub $user) + { + } }; $this->expectException(\Rareloop\Lumberjack\Exceptions\MismatchedContextException::class); diff --git a/tests/Unit/PostQueryTest.php b/tests/Unit/PostQueryTest.php index 1266f1ff..96b7e934 100644 --- a/tests/Unit/PostQueryTest.php +++ b/tests/Unit/PostQueryTest.php @@ -20,7 +20,7 @@ public function it_extends_the_timber_post_query_class(): void $wpQuery = Mockery::mock(WP_Query::class); $wpQuery->found_posts = 0; $wpQuery->posts = []; - + $postQuery = new PostQuery($wpQuery); $this->assertInstanceOf(TimberPostQuery::class, $postQuery); diff --git a/tests/Unit/PostTypeTest.php b/tests/Unit/PostTypeTest.php index 07454a94..d2e2d2fc 100644 --- a/tests/Unit/PostTypeTest.php +++ b/tests/Unit/PostTypeTest.php @@ -30,7 +30,7 @@ public function can_get_post_class_associated_with_post_type(): void $wpPostType = new \stdClass(); $wpPostType->name = 'book'; Functions\expect('get_post_type_object')->andReturn($wpPostType); - + $postType = new PostType('book'); Filters\expectApplied('timber/post/classmap') @@ -46,7 +46,7 @@ public function can_extend_post_type_with_macros(): void $wpPostType = new \stdClass(); $wpPostType->name = 'post'; Functions\expect('get_post_type_object')->andReturn($wpPostType); - + PostType::macro('testMacro', function () { return 'macro_result'; }); diff --git a/tests/Unit/Providers/TimberServiceProviderTest.php b/tests/Unit/Providers/TimberServiceProviderTest.php index 59bea4c0..4c27e814 100644 --- a/tests/Unit/Providers/TimberServiceProviderTest.php +++ b/tests/Unit/Providers/TimberServiceProviderTest.php @@ -17,7 +17,6 @@ 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; diff --git a/tests/Unit/TermTest.php b/tests/Unit/TermTest.php index ef393b43..3bf377cd 100644 --- a/tests/Unit/TermTest.php +++ b/tests/Unit/TermTest.php @@ -36,6 +36,11 @@ public function can_extend_term_with_macros(): void class TestableTerm extends Term { - public function __construct() {} - public static function create() { return new static(); } + public function __construct() + { + } + public static function create() + { + return new static(); + } } diff --git a/tests/Unit/UserTest.php b/tests/Unit/UserTest.php index fd591f55..98938629 100644 --- a/tests/Unit/UserTest.php +++ b/tests/Unit/UserTest.php @@ -36,6 +36,11 @@ public function can_extend_user_with_macros(): void class TestableUser extends User { - public function __construct() {} - public static function create() { return new static(); } + public function __construct() + { + } + public static function create() + { + return new static(); + } } From c4cc72519c876f75da40fcd4fb6c99785381b8cc Mon Sep 17 00:00:00 2001 From: Tom Mitchelmore Date: Mon, 18 May 2026 15:50:57 +0100 Subject: [PATCH 04/27] Optimize DI resolvers and clean up service provider following review - Optimized AbstractContextResolver loop to avoid collection overhead - Safeguarded PostTypeResolver against non-WordPress contexts - Cleaned up WordPressControllersServiceProvider by extracting core resolver list - Refactored PostQueryResolverTest to reduce repetition - Verified TimberResponse recursion logic and fixed missing Arr import --- .../Resolvers/AbstractContextResolver.php | 35 +++++++++++-------- src/Http/Resolvers/PostTypeResolver.php | 8 +++-- .../WordPressControllersServiceProvider.php | 20 +++++++---- .../Http/Resolvers/PostQueryResolverTest.php | 27 +++++++------- 4 files changed, 52 insertions(+), 38 deletions(-) diff --git a/src/Http/Resolvers/AbstractContextResolver.php b/src/Http/Resolvers/AbstractContextResolver.php index 1889c357..89ad7232 100644 --- a/src/Http/Resolvers/AbstractContextResolver.php +++ b/src/Http/Resolvers/AbstractContextResolver.php @@ -16,23 +16,28 @@ public function getParameters( array $providedParameters, array $resolvedParameters ): array { - return collect($reflection->getParameters()) - ->reject(fn($p) => Arr::has($resolvedParameters, $p->getPosition())) - ->filter(fn($p) => $this->canResolve($p)) - ->reduce(function ($resolved, $p) { - try { - $resolved[$p->getPosition()] = $this->resolve($p); - } catch (MissingContextException $e) { - // If the context is entirely missing, we allow null if the typehint supports it - if (!$p->allowsNull()) { - throw $e; - } - - $resolved[$p->getPosition()] = null; + foreach ($reflection->getParameters() as $parameter) { + if (Arr::has($resolvedParameters, $parameter->getPosition())) { + continue; + } + + if (!$this->canResolve($parameter)) { + continue; + } + + try { + $resolvedParameters[$parameter->getPosition()] = $this->resolve($parameter); + } catch (MissingContextException $e) { + // If the context is entirely missing, we allow null if the typehint supports it + if (!$parameter->allowsNull()) { + throw $e; } - return $resolved; - }, $resolvedParameters); + $resolvedParameters[$parameter->getPosition()] = null; + } + } + + return $resolvedParameters; } abstract protected function canResolve(ReflectionParameter $parameter): bool; diff --git a/src/Http/Resolvers/PostTypeResolver.php b/src/Http/Resolvers/PostTypeResolver.php index c7d73073..ecf6f16a 100644 --- a/src/Http/Resolvers/PostTypeResolver.php +++ b/src/Http/Resolvers/PostTypeResolver.php @@ -50,10 +50,12 @@ private function getPostTypeFromQueriedObject($queriedObject): ?PostType } if (is_a($queriedObject, 'WP_Post')) { - $postTypeObject = get_post_type_object($queriedObject->post_type); + if (function_exists('get_post_type_object')) { + $postTypeObject = get_post_type_object($queriedObject->post_type); - if ($postTypeObject) { - return new PostType($postTypeObject->name); + if ($postTypeObject) { + return new PostType($postTypeObject->name); + } } } diff --git a/src/Providers/WordPressControllersServiceProvider.php b/src/Providers/WordPressControllersServiceProvider.php index 5aeeccfa..8c913d45 100644 --- a/src/Providers/WordPressControllersServiceProvider.php +++ b/src/Providers/WordPressControllersServiceProvider.php @@ -27,19 +27,25 @@ public function register() $resolverChain = $invoker->getParameterResolver(); collect($app->get('config')->get('app.resolvers', [])) - ->merge([ - PostTypeResolver::class, - PostQueryResolver::class, - PostResolver::class, - TermResolver::class, - UserResolver::class, - ]) + ->merge($this->getCoreResolvers()) ->reverse() ->each(fn($resolver) => $resolverChain->prependResolver($app->make($resolver))); return $invoker; }); } + + protected function getCoreResolvers(): array + { + return [ + PostTypeResolver::class, + PostQueryResolver::class, + PostResolver::class, + TermResolver::class, + UserResolver::class, + ]; + } + public function boot() { add_filter('template_include', [$this, 'handleTemplateInclude']); diff --git a/tests/Unit/Http/Resolvers/PostQueryResolverTest.php b/tests/Unit/Http/Resolvers/PostQueryResolverTest.php index 7a309ba2..7cee4d15 100644 --- a/tests/Unit/Http/Resolvers/PostQueryResolverTest.php +++ b/tests/Unit/Http/Resolvers/PostQueryResolverTest.php @@ -40,11 +40,7 @@ protected function setUp(): void #[Test] public function it_can_resolve_a_timber_post_query(): void { - 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 = []; + $wpQuery = $this->mockWpQuery(); $this->app->bind(WP_Query::class, $wpQuery); $controller = new class { @@ -62,11 +58,7 @@ public function handle(\Timber\PostQuery $query) #[Test] public function it_can_resolve_a_post_collection_interface(): void { - 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 = []; + $wpQuery = $this->mockWpQuery(); $this->app->bind(WP_Query::class, $wpQuery); $controller = new class { @@ -84,9 +76,7 @@ public function handle(\Timber\PostCollectionInterface $query) #[Test] public function it_can_resolve_a_subclass_of_timber_post_query(): void { - $wpQuery = Mockery::mock(WP_Query::class); - $wpQuery->found_posts = 0; - $wpQuery->posts = []; + $wpQuery = $this->mockWpQuery(); $this->app->bind(WP_Query::class, $wpQuery); $controller = new class { @@ -100,4 +90,15 @@ public function handle(PostQueryStub $query) $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; + } } From 352ad2d8f1715605b14143b5a79bfb89fa15d77e Mon Sep 17 00:00:00 2001 From: Tom Mitchelmore Date: Mon, 18 May 2026 15:50:57 +0100 Subject: [PATCH 05/27] Optimize DI resolvers and clean up service provider following Orbit review - Optimized AbstractContextResolver loop to avoid collection overhead - Safeguarded PostTypeResolver against non-WordPress contexts - Cleaned up WordPressControllersServiceProvider by extracting core resolver list - Refactored PostQueryResolverTest to reduce repetition - Verified TimberResponse recursion logic and fixed missing Arr import --- .../Resolvers/AbstractContextResolver.php | 35 +++++++++++-------- src/Http/Resolvers/PostTypeResolver.php | 8 +++-- .../WordPressControllersServiceProvider.php | 20 +++++++---- .../Http/Resolvers/PostQueryResolverTest.php | 27 +++++++------- 4 files changed, 52 insertions(+), 38 deletions(-) diff --git a/src/Http/Resolvers/AbstractContextResolver.php b/src/Http/Resolvers/AbstractContextResolver.php index 1889c357..89ad7232 100644 --- a/src/Http/Resolvers/AbstractContextResolver.php +++ b/src/Http/Resolvers/AbstractContextResolver.php @@ -16,23 +16,28 @@ public function getParameters( array $providedParameters, array $resolvedParameters ): array { - return collect($reflection->getParameters()) - ->reject(fn($p) => Arr::has($resolvedParameters, $p->getPosition())) - ->filter(fn($p) => $this->canResolve($p)) - ->reduce(function ($resolved, $p) { - try { - $resolved[$p->getPosition()] = $this->resolve($p); - } catch (MissingContextException $e) { - // If the context is entirely missing, we allow null if the typehint supports it - if (!$p->allowsNull()) { - throw $e; - } - - $resolved[$p->getPosition()] = null; + foreach ($reflection->getParameters() as $parameter) { + if (Arr::has($resolvedParameters, $parameter->getPosition())) { + continue; + } + + if (!$this->canResolve($parameter)) { + continue; + } + + try { + $resolvedParameters[$parameter->getPosition()] = $this->resolve($parameter); + } catch (MissingContextException $e) { + // If the context is entirely missing, we allow null if the typehint supports it + if (!$parameter->allowsNull()) { + throw $e; } - return $resolved; - }, $resolvedParameters); + $resolvedParameters[$parameter->getPosition()] = null; + } + } + + return $resolvedParameters; } abstract protected function canResolve(ReflectionParameter $parameter): bool; diff --git a/src/Http/Resolvers/PostTypeResolver.php b/src/Http/Resolvers/PostTypeResolver.php index c7d73073..ecf6f16a 100644 --- a/src/Http/Resolvers/PostTypeResolver.php +++ b/src/Http/Resolvers/PostTypeResolver.php @@ -50,10 +50,12 @@ private function getPostTypeFromQueriedObject($queriedObject): ?PostType } if (is_a($queriedObject, 'WP_Post')) { - $postTypeObject = get_post_type_object($queriedObject->post_type); + if (function_exists('get_post_type_object')) { + $postTypeObject = get_post_type_object($queriedObject->post_type); - if ($postTypeObject) { - return new PostType($postTypeObject->name); + if ($postTypeObject) { + return new PostType($postTypeObject->name); + } } } diff --git a/src/Providers/WordPressControllersServiceProvider.php b/src/Providers/WordPressControllersServiceProvider.php index 5aeeccfa..8c913d45 100644 --- a/src/Providers/WordPressControllersServiceProvider.php +++ b/src/Providers/WordPressControllersServiceProvider.php @@ -27,19 +27,25 @@ public function register() $resolverChain = $invoker->getParameterResolver(); collect($app->get('config')->get('app.resolvers', [])) - ->merge([ - PostTypeResolver::class, - PostQueryResolver::class, - PostResolver::class, - TermResolver::class, - UserResolver::class, - ]) + ->merge($this->getCoreResolvers()) ->reverse() ->each(fn($resolver) => $resolverChain->prependResolver($app->make($resolver))); return $invoker; }); } + + protected function getCoreResolvers(): array + { + return [ + PostTypeResolver::class, + PostQueryResolver::class, + PostResolver::class, + TermResolver::class, + UserResolver::class, + ]; + } + public function boot() { add_filter('template_include', [$this, 'handleTemplateInclude']); diff --git a/tests/Unit/Http/Resolvers/PostQueryResolverTest.php b/tests/Unit/Http/Resolvers/PostQueryResolverTest.php index 7a309ba2..7cee4d15 100644 --- a/tests/Unit/Http/Resolvers/PostQueryResolverTest.php +++ b/tests/Unit/Http/Resolvers/PostQueryResolverTest.php @@ -40,11 +40,7 @@ protected function setUp(): void #[Test] public function it_can_resolve_a_timber_post_query(): void { - 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 = []; + $wpQuery = $this->mockWpQuery(); $this->app->bind(WP_Query::class, $wpQuery); $controller = new class { @@ -62,11 +58,7 @@ public function handle(\Timber\PostQuery $query) #[Test] public function it_can_resolve_a_post_collection_interface(): void { - 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 = []; + $wpQuery = $this->mockWpQuery(); $this->app->bind(WP_Query::class, $wpQuery); $controller = new class { @@ -84,9 +76,7 @@ public function handle(\Timber\PostCollectionInterface $query) #[Test] public function it_can_resolve_a_subclass_of_timber_post_query(): void { - $wpQuery = Mockery::mock(WP_Query::class); - $wpQuery->found_posts = 0; - $wpQuery->posts = []; + $wpQuery = $this->mockWpQuery(); $this->app->bind(WP_Query::class, $wpQuery); $controller = new class { @@ -100,4 +90,15 @@ public function handle(PostQueryStub $query) $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; + } } From 9e9721a4376e93ffb11085134162dc41e8d1b27c Mon Sep 17 00:00:00 2001 From: Tom Mitchelmore Date: Mon, 18 May 2026 16:33:18 +0100 Subject: [PATCH 06/27] Refactor DI system to Template Method pattern and consolidate tests - DRYed up context resolvers by moving reflection and validation logic to AbstractContextResolver - Simplified concrete resolvers to implement canResolveClass and resolveObject - Added AbstractContextResolverTest to cover common DI edge cases (built-ins, nullables, mismatches) - Optimized existing resolver tests by removing repetitive boilerplate --- .gitignore | 1 + .../Resolvers/AbstractContextResolver.php | 55 +++++++- src/Http/Resolvers/PostQueryResolver.php | 25 ++-- src/Http/Resolvers/PostResolver.php | 35 +---- src/Http/Resolvers/PostTypeResolver.php | 43 +----- src/Http/Resolvers/TermResolver.php | 35 +---- src/Http/Resolvers/UserResolver.php | 35 +---- src/PostQuery.php | 11 ++ .../Resolvers/AbstractContextResolverTest.php | 126 ++++++++++++++++++ .../Unit/Http/Resolvers/PostResolverTest.php | 56 -------- .../Http/Resolvers/PostTypeResolverTest.php | 15 +++ .../Unit/Http/Resolvers/TermResolverTest.php | 54 -------- .../Unit/Http/Resolvers/UserResolverTest.php | 54 -------- tests/Unit/PostQueryTest.php | 14 ++ 14 files changed, 244 insertions(+), 315 deletions(-) create mode 100644 tests/Unit/Http/Resolvers/AbstractContextResolverTest.php diff --git a/.gitignore b/.gitignore index e24d6c76..7bc6b636 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ composer.lock /tests/Unit/logs .phpunit.result.cache .phpunit.cache +coverage_report diff --git a/src/Http/Resolvers/AbstractContextResolver.php b/src/Http/Resolvers/AbstractContextResolver.php index 89ad7232..bc3358c0 100644 --- a/src/Http/Resolvers/AbstractContextResolver.php +++ b/src/Http/Resolvers/AbstractContextResolver.php @@ -4,10 +4,9 @@ use Illuminate\Support\Arr; use Invoker\ParameterResolver\ParameterResolver; +use Rareloop\Lumberjack\Exceptions\MismatchedContextException; use Rareloop\Lumberjack\Exceptions\MissingContextException; -use Rareloop\Lumberjack\Exceptions\UnresolvableContextException; use ReflectionFunctionAbstract; -use ReflectionParameter; abstract class AbstractContextResolver implements ParameterResolver { @@ -21,12 +20,32 @@ public function getParameters( continue; } - if (!$this->canResolve($parameter)) { + $type = $parameter->getType(); + + if (!$type || $type->isBuiltin()) { + continue; + } + + $className = $type->getName(); + + if (!$this->canResolveClass($className)) { continue; } try { - $resolvedParameters[$parameter->getPosition()] = $this->resolve($parameter); + $context = $this->getContext(); + + if (is_null($context)) { + throw MissingContextException::forType($className, $context); + } + + $resolvedObject = $this->resolveObject($className, $context); + + if (!$resolvedObject instanceof $className) { + throw MismatchedContextException::forIncorrectClass($className, $resolvedObject); + } + + $resolvedParameters[$parameter->getPosition()] = $resolvedObject; } catch (MissingContextException $e) { // If the context is entirely missing, we allow null if the typehint supports it if (!$parameter->allowsNull()) { @@ -40,7 +59,31 @@ public function getParameters( return $resolvedParameters; } - abstract protected function canResolve(ReflectionParameter $parameter): bool; + /** + * 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; - abstract protected function resolve(ReflectionParameter $parameter): mixed; + /** + * 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 index b609c27a..cb5f9aed 100644 --- a/src/Http/Resolvers/PostQueryResolver.php +++ b/src/Http/Resolvers/PostQueryResolver.php @@ -3,7 +3,6 @@ namespace Rareloop\Lumberjack\Http\Resolvers; use Rareloop\Lumberjack\Application; -use ReflectionParameter; use Timber\PostQuery as TimberPostQuery; use Timber\PostCollectionInterface; use Timber\Timber; @@ -15,35 +14,27 @@ public function __construct(protected Application $app) { } - protected function canResolve(ReflectionParameter $parameter): bool + protected function canResolveClass(string $className): bool { - $type = $parameter->getType(); - - if (!$type || $type->isBuiltin()) { - return false; - } - - $className = $type->getName(); - return $className === TimberPostQuery::class || $className === PostCollectionInterface::class || is_subclass_of($className, TimberPostQuery::class); } - protected function resolve(ReflectionParameter $parameter): mixed + protected function getContext(): mixed { - $className = $parameter->getType()->getName(); - - // Resolve WP_Query from the container instead of globals - $query = $this->app->get(WP_Query::class); + return $this->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($query); + 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($query); + return new $className($context); } } diff --git a/src/Http/Resolvers/PostResolver.php b/src/Http/Resolvers/PostResolver.php index c14108f6..ad6d3208 100644 --- a/src/Http/Resolvers/PostResolver.php +++ b/src/Http/Resolvers/PostResolver.php @@ -2,42 +2,19 @@ namespace Rareloop\Lumberjack\Http\Resolvers; -use Rareloop\Lumberjack\Exceptions\MismatchedContextException; -use Rareloop\Lumberjack\Exceptions\MissingContextException; -use ReflectionParameter; -use Timber\Post; +use Rareloop\Lumberjack\Post; +use Timber\Post as TimberPost; use Timber\Timber; class PostResolver extends AbstractContextResolver { - protected function canResolve(ReflectionParameter $parameter): bool + protected function canResolveClass(string $className): bool { - $type = $parameter->getType(); - - if (!$type || $type->isBuiltin()) { - return false; - } - - $className = $type->getName(); - - return $className === Post::class || is_subclass_of($className, Post::class); + return is_a($className, Post::class, true) || is_a($className, TimberPost::class, true); } - protected function resolve(ReflectionParameter $parameter): mixed + protected function resolveObject(string $className, mixed $context): mixed { - $className = $parameter->getType()->getName(); - $queriedObject = get_queried_object(); - - if (!$queriedObject instanceof \WP_Post) { - throw MissingContextException::forType($className, $queriedObject); - } - - $post = Timber::get_post($queriedObject); - - if (!$post instanceof $className) { - throw MismatchedContextException::forIncorrectClass($className, $post); - } - - return $post; + return Timber::get_post($context); } } diff --git a/src/Http/Resolvers/PostTypeResolver.php b/src/Http/Resolvers/PostTypeResolver.php index ecf6f16a..692d2eae 100644 --- a/src/Http/Resolvers/PostTypeResolver.php +++ b/src/Http/Resolvers/PostTypeResolver.php @@ -2,56 +2,25 @@ namespace Rareloop\Lumberjack\Http\Resolvers; -use Rareloop\Lumberjack\Exceptions\MismatchedContextException; -use Rareloop\Lumberjack\Exceptions\MissingContextException; use Rareloop\Lumberjack\PostType; -use ReflectionParameter; use Timber\PostType as TimberPostType; -use WP_Post; -use WP_Post_Type; class PostTypeResolver extends AbstractContextResolver { - protected function canResolve(ReflectionParameter $parameter): bool + protected function canResolveClass(string $className): bool { - $type = $parameter->getType(); - - if (!$type || $type->isBuiltin()) { - return false; - } - - $className = $type->getName(); - return is_a($className, PostType::class, true) || is_a($className, TimberPostType::class, true); } - protected function resolve(ReflectionParameter $parameter): mixed - { - $className = $parameter->getType()->getName(); - $queriedObject = get_queried_object(); - - $postType = $this->getPostTypeFromQueriedObject($queriedObject); - - if (!$postType) { - throw MissingContextException::forType($className, $queriedObject); - } - - if (!$postType instanceof $className) { - throw MismatchedContextException::forIncorrectClass($className, $postType); - } - - return $postType; - } - - private function getPostTypeFromQueriedObject($queriedObject): ?PostType + protected function resolveObject(string $className, mixed $context): mixed { - if (is_a($queriedObject, 'WP_Post_Type')) { - return new PostType($queriedObject->name); + if (is_a($context, 'WP_Post_Type')) { + return new PostType($context->name); } - if (is_a($queriedObject, 'WP_Post')) { + if (is_a($context, 'WP_Post')) { if (function_exists('get_post_type_object')) { - $postTypeObject = get_post_type_object($queriedObject->post_type); + $postTypeObject = get_post_type_object($context->post_type); if ($postTypeObject) { return new PostType($postTypeObject->name); diff --git a/src/Http/Resolvers/TermResolver.php b/src/Http/Resolvers/TermResolver.php index cd35e4d4..95e173ef 100644 --- a/src/Http/Resolvers/TermResolver.php +++ b/src/Http/Resolvers/TermResolver.php @@ -2,46 +2,19 @@ namespace Rareloop\Lumberjack\Http\Resolvers; -use Rareloop\Lumberjack\Exceptions\MismatchedContextException; -use Rareloop\Lumberjack\Exceptions\MissingContextException; use Rareloop\Lumberjack\Term; -use ReflectionParameter; use Timber\Term as TimberTerm; use Timber\Timber; class TermResolver extends AbstractContextResolver { - protected function canResolve(ReflectionParameter $parameter): bool + protected function canResolveClass(string $className): bool { - $type = $parameter->getType(); - - if (!$type || $type->isBuiltin()) { - return false; - } - - $className = $type->getName(); - - return $className === Term::class - || is_subclass_of($className, Term::class) - || $className === TimberTerm::class - || is_subclass_of($className, TimberTerm::class); + return is_a($className, Term::class, true) || is_a($className, TimberTerm::class, true); } - protected function resolve(ReflectionParameter $parameter): mixed + protected function resolveObject(string $className, mixed $context): mixed { - $className = $parameter->getType()->getName(); - $queriedObject = get_queried_object(); - - if (!$queriedObject instanceof \WP_Term) { - throw MissingContextException::forType($className, $queriedObject); - } - - $term = Timber::get_term($queriedObject); - - if (!$term instanceof $className) { - throw MismatchedContextException::forIncorrectClass($className, $term); - } - - return $term; + return Timber::get_term($context); } } diff --git a/src/Http/Resolvers/UserResolver.php b/src/Http/Resolvers/UserResolver.php index 2ffe1dcf..29f69ae2 100644 --- a/src/Http/Resolvers/UserResolver.php +++ b/src/Http/Resolvers/UserResolver.php @@ -2,46 +2,19 @@ namespace Rareloop\Lumberjack\Http\Resolvers; -use Rareloop\Lumberjack\Exceptions\MismatchedContextException; -use Rareloop\Lumberjack\Exceptions\MissingContextException; use Rareloop\Lumberjack\User; -use ReflectionParameter; use Timber\User as TimberUser; use Timber\Timber; class UserResolver extends AbstractContextResolver { - protected function canResolve(ReflectionParameter $parameter): bool + protected function canResolveClass(string $className): bool { - $type = $parameter->getType(); - - if (!$type || $type->isBuiltin()) { - return false; - } - - $className = $type->getName(); - - return $className === User::class - || is_subclass_of($className, User::class) - || $className === TimberUser::class - || is_subclass_of($className, TimberUser::class); + return is_a($className, User::class, true) || is_a($className, TimberUser::class, true); } - protected function resolve(ReflectionParameter $parameter): mixed + protected function resolveObject(string $className, mixed $context): mixed { - $className = $parameter->getType()->getName(); - $queriedObject = get_queried_object(); - - if (!$queriedObject instanceof \WP_User) { - throw MissingContextException::forType($className, $queriedObject); - } - - $user = Timber::get_user($queriedObject); - - if (!$user instanceof $className) { - throw MismatchedContextException::forIncorrectClass($className, $user); - } - - return $user; + return Timber::get_user($context); } } diff --git a/src/PostQuery.php b/src/PostQuery.php index ff9b9799..9083cb25 100644 --- a/src/PostQuery.php +++ b/src/PostQuery.php @@ -2,10 +2,21 @@ namespace Rareloop\Lumberjack; +use Illuminate\Support\Collection; use Spatie\Macroable\Macroable; use Timber\PostQuery as TimberPostQuery; class PostQuery extends TimberPostQuery { use Macroable; + + /** + * Get the posts in this query as a Collection. + * + * @return \Illuminate\Support\Collection + */ + public function toCollection(): Collection + { + return new Collection($this->getArrayCopy()); + } } diff --git a/tests/Unit/Http/Resolvers/AbstractContextResolverTest.php b/tests/Unit/Http/Resolvers/AbstractContextResolverTest.php new file mode 100644 index 00000000..6260b080 --- /dev/null +++ b/tests/Unit/Http/Resolvers/AbstractContextResolverTest.php @@ -0,0 +1,126 @@ +assertEmpty($resolver->getParameters($reflection, [], [])); + } + + #[Test] + public function it_ignores_classes_it_cannot_handle(): void + { + $resolver = new class extends AbstractContextResolver { + protected function canResolveClass(string $className): bool + { + return $className === \stdClass::class; + } + protected function resolveObject(string $className, mixed $context): mixed + { + return new \stdClass(); + } + }; + + $reflection = new ReflectionFunction(function (\Exception $e) { + }); + + $this->assertEmpty($resolver->getParameters($reflection, [], [])); + } + + #[Test] + public function it_throws_an_exception_if_context_is_missing_and_not_nullable(): void + { + Functions\expect('get_queried_object')->once()->andReturn(null); + + $resolver = new class extends AbstractContextResolver { + protected function canResolveClass(string $className): bool + { + return true; + } + protected function resolveObject(string $className, mixed $context): mixed + { + return new \stdClass(); + } + }; + + $reflection = new ReflectionFunction(function (\stdClass $obj) { + }); + + $this->expectException(\Rareloop\Lumberjack\Exceptions\MissingContextException::class); + $resolver->getParameters($reflection, [], []); + } + + #[Test] + public function it_resolves_to_null_if_context_is_missing_and_nullable(): void + { + Functions\expect('get_queried_object')->once()->andReturn(null); + + $resolver = new class extends AbstractContextResolver { + protected function canResolveClass(string $className): bool + { + return true; + } + protected function resolveObject(string $className, mixed $context): mixed + { + return new \stdClass(); + } + }; + + $reflection = new ReflectionFunction(function (?\stdClass $obj) { + }); + $resolved = $resolver->getParameters($reflection, [], []); + + $this->assertCount(1, $resolved); + $this->assertNull($resolved[0]); + } + + #[Test] + public function it_throws_an_exception_if_resolved_object_is_wrong_type(): void + { + Functions\expect('get_queried_object')->once()->andReturn(new \stdClass()); + + $resolver = new class extends AbstractContextResolver { + protected function canResolveClass(string $className): bool + { + return true; + } + protected function resolveObject(string $className, mixed $context): mixed + { + return new \Exception(); // Not a stdClass + } + }; + + $reflection = new ReflectionFunction(function (\stdClass $obj) { + }); + + $this->expectException(\Rareloop\Lumberjack\Exceptions\MismatchedContextException::class); + $resolver->getParameters($reflection, [], []); + } +} diff --git a/tests/Unit/Http/Resolvers/PostResolverTest.php b/tests/Unit/Http/Resolvers/PostResolverTest.php index 38481eeb..d2700109 100644 --- a/tests/Unit/Http/Resolvers/PostResolverTest.php +++ b/tests/Unit/Http/Resolvers/PostResolverTest.php @@ -6,7 +6,6 @@ use Mockery; use PHPUnit\Framework\Attributes\Test; use Rareloop\Lumberjack\Application; -use Rareloop\Lumberjack\Http\Resolvers\PostResolver; use Rareloop\Lumberjack\Providers\WordPressControllersServiceProvider; use Rareloop\Lumberjack\Test\TestCase; use Rareloop\Lumberjack\Test\Unit\Concerns\BrainMonkeyPHPUnitIntegration; @@ -37,7 +36,6 @@ protected function setUp(): void $config->shouldReceive('get')->with('app.resolvers', [])->andReturn([]); $this->app->bind('config', $config); - // Register the provider so the Invoker is set up with all resolvers $provider = new WordPressControllersServiceProvider($this->app); $provider->register(); @@ -87,58 +85,4 @@ public function handle(PostStub $post) $this->assertSame($postStub, $result); } - - #[Test] - public function it_throws_an_exception_if_the_queried_object_is_not_a_post(): void - { - Functions\expect('get_queried_object')->once()->andReturn(null); - - $controller = new class { - public function handle(Post $post) - { - } - }; - - $this->expectException(\Rareloop\Lumberjack\Exceptions\MissingContextException::class); - - $this->invoker->call([$controller, 'handle']); - } - - #[Test] - public function it_resolves_to_null_if_the_typehint_is_nullable_and_context_is_missing(): void - { - Functions\expect('get_queried_object')->once()->andReturn(null); - - $controller = new class { - public function handle(?Post $post) - { - return $post; - } - }; - - $result = $this->invoker->call([$controller, 'handle']); - - $this->assertNull($result); - } - - #[Test] - public function it_throws_an_exception_if_the_resolved_post_is_not_of_the_expected_typehinted_class(): void - { - $wpPost = Mockery::mock(WP_Post::class); - Functions\expect('get_queried_object')->once()->andReturn($wpPost); - - $timberPost = Mockery::mock(Post::class); // Not a PostStub - $timber = Mockery::mock('alias:' . Timber::class); - $timber->shouldReceive('get_post')->once()->with($wpPost)->andReturn($timberPost); - - $controller = new class { - public function handle(PostStub $post) - { - } - }; - - $this->expectException(\Rareloop\Lumberjack\Exceptions\MismatchedContextException::class); - - $this->invoker->call([$controller, 'handle']); - } } diff --git a/tests/Unit/Http/Resolvers/PostTypeResolverTest.php b/tests/Unit/Http/Resolvers/PostTypeResolverTest.php index 608a714c..417c65bd 100644 --- a/tests/Unit/Http/Resolvers/PostTypeResolverTest.php +++ b/tests/Unit/Http/Resolvers/PostTypeResolverTest.php @@ -139,4 +139,19 @@ public function handle(?LumberjackPostType $postType) $this->assertNull($result); } + + #[Test] + public function it_ignores_builtin_typehints(): void + { + $controller = new class { + public function handle(int $id) + { + return $id; + } + }; + + $result = $this->invoker->call([$controller, 'handle'], ['id' => 123]); + + $this->assertSame(123, $result); + } } diff --git a/tests/Unit/Http/Resolvers/TermResolverTest.php b/tests/Unit/Http/Resolvers/TermResolverTest.php index 54d8095b..7c2961cb 100644 --- a/tests/Unit/Http/Resolvers/TermResolverTest.php +++ b/tests/Unit/Http/Resolvers/TermResolverTest.php @@ -85,58 +85,4 @@ public function handle(TermStub $term) $this->assertSame($termStub, $result); } - - #[Test] - public function it_throws_an_exception_if_the_queried_object_is_not_a_term(): void - { - Functions\expect('get_queried_object')->once()->andReturn(null); - - $controller = new class { - public function handle(Term $term) - { - } - }; - - $this->expectException(\Rareloop\Lumberjack\Exceptions\MissingContextException::class); - - $this->invoker->call([$controller, 'handle']); - } - - #[Test] - public function it_resolves_to_null_if_the_typehint_is_nullable_and_context_is_missing(): void - { - Functions\expect('get_queried_object')->once()->andReturn(null); - - $controller = new class { - public function handle(?Term $term) - { - return $term; - } - }; - - $result = $this->invoker->call([$controller, 'handle']); - - $this->assertNull($result); - } - - #[Test] - public function it_throws_an_exception_if_the_resolved_term_is_not_of_the_expected_typehinted_class(): void - { - $wpTerm = Mockery::mock(WP_Term::class); - Functions\expect('get_queried_object')->once()->andReturn($wpTerm); - - $timberTerm = Mockery::mock(Term::class); // Not a TermStub - $timber = Mockery::mock('alias:' . Timber::class); - $timber->shouldReceive('get_term')->once()->with($wpTerm)->andReturn($timberTerm); - - $controller = new class { - public function handle(TermStub $term) - { - } - }; - - $this->expectException(\Rareloop\Lumberjack\Exceptions\MismatchedContextException::class); - - $this->invoker->call([$controller, 'handle']); - } } diff --git a/tests/Unit/Http/Resolvers/UserResolverTest.php b/tests/Unit/Http/Resolvers/UserResolverTest.php index 81af0de3..68058005 100644 --- a/tests/Unit/Http/Resolvers/UserResolverTest.php +++ b/tests/Unit/Http/Resolvers/UserResolverTest.php @@ -85,58 +85,4 @@ public function handle(UserStub $user) $this->assertSame($userStub, $result); } - - #[Test] - public function it_throws_an_exception_if_the_queried_object_is_not_a_user(): void - { - Functions\expect('get_queried_object')->once()->andReturn(null); - - $controller = new class { - public function handle(User $user) - { - } - }; - - $this->expectException(\Rareloop\Lumberjack\Exceptions\MissingContextException::class); - - $this->invoker->call([$controller, 'handle']); - } - - #[Test] - public function it_resolves_to_null_if_the_typehint_is_nullable_and_context_is_missing(): void - { - Functions\expect('get_queried_object')->once()->andReturn(null); - - $controller = new class { - public function handle(?User $user) - { - return $user; - } - }; - - $result = $this->invoker->call([$controller, 'handle']); - - $this->assertNull($result); - } - - #[Test] - public function it_throws_an_exception_if_the_resolved_user_is_not_of_the_expected_typehinted_class(): void - { - $wpUser = Mockery::mock(WP_User::class); - Functions\expect('get_queried_object')->once()->andReturn($wpUser); - - $timberUser = Mockery::mock(User::class); // Not a UserStub - $timber = Mockery::mock('alias:' . Timber::class); - $timber->shouldReceive('get_user')->once()->with($wpUser)->andReturn($timberUser); - - $controller = new class { - public function handle(UserStub $user) - { - } - }; - - $this->expectException(\Rareloop\Lumberjack\Exceptions\MismatchedContextException::class); - - $this->invoker->call([$controller, 'handle']); - } } diff --git a/tests/Unit/PostQueryTest.php b/tests/Unit/PostQueryTest.php index 96b7e934..6e01bc6e 100644 --- a/tests/Unit/PostQueryTest.php +++ b/tests/Unit/PostQueryTest.php @@ -26,6 +26,20 @@ public function it_extends_the_timber_post_query_class(): void $this->assertInstanceOf(TimberPostQuery::class, $postQuery); } + #[Test] + public function can_convert_to_collection(): void + { + $wpQuery = Mockery::mock(WP_Query::class); + $wpQuery->found_posts = 1; + $wpQuery->posts = [new \stdClass()]; + + $postQuery = new PostQuery($wpQuery); + $collection = $postQuery->toCollection(); + + $this->assertInstanceOf(\Illuminate\Support\Collection::class, $collection); + $this->assertCount(1, $collection); + } + #[Test] public function can_extend_post_query_with_macros(): void { From ddcd3deffeafa150c4a082c15b2a04af8ffc9cf0 Mon Sep 17 00:00:00 2001 From: Tom Mitchelmore Date: Mon, 18 May 2026 16:33:18 +0100 Subject: [PATCH 07/27] Refactor DI system to Template Method pattern and consolidate tests - DRYed up context resolvers by moving reflection and validation logic to AbstractContextResolver - Simplified concrete resolvers to implement canResolveClass and resolveObject - Added AbstractContextResolverTest to cover common DI edge cases (built-ins, nullables, mismatches) - Optimized existing resolver tests by removing repetitive boilerplate --- .gitignore | 1 + .../Resolvers/AbstractContextResolver.php | 55 +++++++- src/Http/Resolvers/PostQueryResolver.php | 25 ++-- src/Http/Resolvers/PostResolver.php | 35 +---- src/Http/Resolvers/PostTypeResolver.php | 43 +----- src/Http/Resolvers/TermResolver.php | 35 +---- src/Http/Resolvers/UserResolver.php | 35 +---- src/PostQuery.php | 11 ++ .../Resolvers/AbstractContextResolverTest.php | 126 ++++++++++++++++++ .../Unit/Http/Resolvers/PostResolverTest.php | 56 -------- .../Http/Resolvers/PostTypeResolverTest.php | 15 +++ .../Unit/Http/Resolvers/TermResolverTest.php | 54 -------- .../Unit/Http/Resolvers/UserResolverTest.php | 54 -------- tests/Unit/PostQueryTest.php | 14 ++ 14 files changed, 244 insertions(+), 315 deletions(-) create mode 100644 tests/Unit/Http/Resolvers/AbstractContextResolverTest.php diff --git a/.gitignore b/.gitignore index e24d6c76..7bc6b636 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ composer.lock /tests/Unit/logs .phpunit.result.cache .phpunit.cache +coverage_report diff --git a/src/Http/Resolvers/AbstractContextResolver.php b/src/Http/Resolvers/AbstractContextResolver.php index 89ad7232..bc3358c0 100644 --- a/src/Http/Resolvers/AbstractContextResolver.php +++ b/src/Http/Resolvers/AbstractContextResolver.php @@ -4,10 +4,9 @@ use Illuminate\Support\Arr; use Invoker\ParameterResolver\ParameterResolver; +use Rareloop\Lumberjack\Exceptions\MismatchedContextException; use Rareloop\Lumberjack\Exceptions\MissingContextException; -use Rareloop\Lumberjack\Exceptions\UnresolvableContextException; use ReflectionFunctionAbstract; -use ReflectionParameter; abstract class AbstractContextResolver implements ParameterResolver { @@ -21,12 +20,32 @@ public function getParameters( continue; } - if (!$this->canResolve($parameter)) { + $type = $parameter->getType(); + + if (!$type || $type->isBuiltin()) { + continue; + } + + $className = $type->getName(); + + if (!$this->canResolveClass($className)) { continue; } try { - $resolvedParameters[$parameter->getPosition()] = $this->resolve($parameter); + $context = $this->getContext(); + + if (is_null($context)) { + throw MissingContextException::forType($className, $context); + } + + $resolvedObject = $this->resolveObject($className, $context); + + if (!$resolvedObject instanceof $className) { + throw MismatchedContextException::forIncorrectClass($className, $resolvedObject); + } + + $resolvedParameters[$parameter->getPosition()] = $resolvedObject; } catch (MissingContextException $e) { // If the context is entirely missing, we allow null if the typehint supports it if (!$parameter->allowsNull()) { @@ -40,7 +59,31 @@ public function getParameters( return $resolvedParameters; } - abstract protected function canResolve(ReflectionParameter $parameter): bool; + /** + * 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; - abstract protected function resolve(ReflectionParameter $parameter): mixed; + /** + * 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 index b609c27a..cb5f9aed 100644 --- a/src/Http/Resolvers/PostQueryResolver.php +++ b/src/Http/Resolvers/PostQueryResolver.php @@ -3,7 +3,6 @@ namespace Rareloop\Lumberjack\Http\Resolvers; use Rareloop\Lumberjack\Application; -use ReflectionParameter; use Timber\PostQuery as TimberPostQuery; use Timber\PostCollectionInterface; use Timber\Timber; @@ -15,35 +14,27 @@ public function __construct(protected Application $app) { } - protected function canResolve(ReflectionParameter $parameter): bool + protected function canResolveClass(string $className): bool { - $type = $parameter->getType(); - - if (!$type || $type->isBuiltin()) { - return false; - } - - $className = $type->getName(); - return $className === TimberPostQuery::class || $className === PostCollectionInterface::class || is_subclass_of($className, TimberPostQuery::class); } - protected function resolve(ReflectionParameter $parameter): mixed + protected function getContext(): mixed { - $className = $parameter->getType()->getName(); - - // Resolve WP_Query from the container instead of globals - $query = $this->app->get(WP_Query::class); + return $this->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($query); + 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($query); + return new $className($context); } } diff --git a/src/Http/Resolvers/PostResolver.php b/src/Http/Resolvers/PostResolver.php index c14108f6..ad6d3208 100644 --- a/src/Http/Resolvers/PostResolver.php +++ b/src/Http/Resolvers/PostResolver.php @@ -2,42 +2,19 @@ namespace Rareloop\Lumberjack\Http\Resolvers; -use Rareloop\Lumberjack\Exceptions\MismatchedContextException; -use Rareloop\Lumberjack\Exceptions\MissingContextException; -use ReflectionParameter; -use Timber\Post; +use Rareloop\Lumberjack\Post; +use Timber\Post as TimberPost; use Timber\Timber; class PostResolver extends AbstractContextResolver { - protected function canResolve(ReflectionParameter $parameter): bool + protected function canResolveClass(string $className): bool { - $type = $parameter->getType(); - - if (!$type || $type->isBuiltin()) { - return false; - } - - $className = $type->getName(); - - return $className === Post::class || is_subclass_of($className, Post::class); + return is_a($className, Post::class, true) || is_a($className, TimberPost::class, true); } - protected function resolve(ReflectionParameter $parameter): mixed + protected function resolveObject(string $className, mixed $context): mixed { - $className = $parameter->getType()->getName(); - $queriedObject = get_queried_object(); - - if (!$queriedObject instanceof \WP_Post) { - throw MissingContextException::forType($className, $queriedObject); - } - - $post = Timber::get_post($queriedObject); - - if (!$post instanceof $className) { - throw MismatchedContextException::forIncorrectClass($className, $post); - } - - return $post; + return Timber::get_post($context); } } diff --git a/src/Http/Resolvers/PostTypeResolver.php b/src/Http/Resolvers/PostTypeResolver.php index ecf6f16a..692d2eae 100644 --- a/src/Http/Resolvers/PostTypeResolver.php +++ b/src/Http/Resolvers/PostTypeResolver.php @@ -2,56 +2,25 @@ namespace Rareloop\Lumberjack\Http\Resolvers; -use Rareloop\Lumberjack\Exceptions\MismatchedContextException; -use Rareloop\Lumberjack\Exceptions\MissingContextException; use Rareloop\Lumberjack\PostType; -use ReflectionParameter; use Timber\PostType as TimberPostType; -use WP_Post; -use WP_Post_Type; class PostTypeResolver extends AbstractContextResolver { - protected function canResolve(ReflectionParameter $parameter): bool + protected function canResolveClass(string $className): bool { - $type = $parameter->getType(); - - if (!$type || $type->isBuiltin()) { - return false; - } - - $className = $type->getName(); - return is_a($className, PostType::class, true) || is_a($className, TimberPostType::class, true); } - protected function resolve(ReflectionParameter $parameter): mixed - { - $className = $parameter->getType()->getName(); - $queriedObject = get_queried_object(); - - $postType = $this->getPostTypeFromQueriedObject($queriedObject); - - if (!$postType) { - throw MissingContextException::forType($className, $queriedObject); - } - - if (!$postType instanceof $className) { - throw MismatchedContextException::forIncorrectClass($className, $postType); - } - - return $postType; - } - - private function getPostTypeFromQueriedObject($queriedObject): ?PostType + protected function resolveObject(string $className, mixed $context): mixed { - if (is_a($queriedObject, 'WP_Post_Type')) { - return new PostType($queriedObject->name); + if (is_a($context, 'WP_Post_Type')) { + return new PostType($context->name); } - if (is_a($queriedObject, 'WP_Post')) { + if (is_a($context, 'WP_Post')) { if (function_exists('get_post_type_object')) { - $postTypeObject = get_post_type_object($queriedObject->post_type); + $postTypeObject = get_post_type_object($context->post_type); if ($postTypeObject) { return new PostType($postTypeObject->name); diff --git a/src/Http/Resolvers/TermResolver.php b/src/Http/Resolvers/TermResolver.php index cd35e4d4..95e173ef 100644 --- a/src/Http/Resolvers/TermResolver.php +++ b/src/Http/Resolvers/TermResolver.php @@ -2,46 +2,19 @@ namespace Rareloop\Lumberjack\Http\Resolvers; -use Rareloop\Lumberjack\Exceptions\MismatchedContextException; -use Rareloop\Lumberjack\Exceptions\MissingContextException; use Rareloop\Lumberjack\Term; -use ReflectionParameter; use Timber\Term as TimberTerm; use Timber\Timber; class TermResolver extends AbstractContextResolver { - protected function canResolve(ReflectionParameter $parameter): bool + protected function canResolveClass(string $className): bool { - $type = $parameter->getType(); - - if (!$type || $type->isBuiltin()) { - return false; - } - - $className = $type->getName(); - - return $className === Term::class - || is_subclass_of($className, Term::class) - || $className === TimberTerm::class - || is_subclass_of($className, TimberTerm::class); + return is_a($className, Term::class, true) || is_a($className, TimberTerm::class, true); } - protected function resolve(ReflectionParameter $parameter): mixed + protected function resolveObject(string $className, mixed $context): mixed { - $className = $parameter->getType()->getName(); - $queriedObject = get_queried_object(); - - if (!$queriedObject instanceof \WP_Term) { - throw MissingContextException::forType($className, $queriedObject); - } - - $term = Timber::get_term($queriedObject); - - if (!$term instanceof $className) { - throw MismatchedContextException::forIncorrectClass($className, $term); - } - - return $term; + return Timber::get_term($context); } } diff --git a/src/Http/Resolvers/UserResolver.php b/src/Http/Resolvers/UserResolver.php index 2ffe1dcf..29f69ae2 100644 --- a/src/Http/Resolvers/UserResolver.php +++ b/src/Http/Resolvers/UserResolver.php @@ -2,46 +2,19 @@ namespace Rareloop\Lumberjack\Http\Resolvers; -use Rareloop\Lumberjack\Exceptions\MismatchedContextException; -use Rareloop\Lumberjack\Exceptions\MissingContextException; use Rareloop\Lumberjack\User; -use ReflectionParameter; use Timber\User as TimberUser; use Timber\Timber; class UserResolver extends AbstractContextResolver { - protected function canResolve(ReflectionParameter $parameter): bool + protected function canResolveClass(string $className): bool { - $type = $parameter->getType(); - - if (!$type || $type->isBuiltin()) { - return false; - } - - $className = $type->getName(); - - return $className === User::class - || is_subclass_of($className, User::class) - || $className === TimberUser::class - || is_subclass_of($className, TimberUser::class); + return is_a($className, User::class, true) || is_a($className, TimberUser::class, true); } - protected function resolve(ReflectionParameter $parameter): mixed + protected function resolveObject(string $className, mixed $context): mixed { - $className = $parameter->getType()->getName(); - $queriedObject = get_queried_object(); - - if (!$queriedObject instanceof \WP_User) { - throw MissingContextException::forType($className, $queriedObject); - } - - $user = Timber::get_user($queriedObject); - - if (!$user instanceof $className) { - throw MismatchedContextException::forIncorrectClass($className, $user); - } - - return $user; + return Timber::get_user($context); } } diff --git a/src/PostQuery.php b/src/PostQuery.php index ff9b9799..9083cb25 100644 --- a/src/PostQuery.php +++ b/src/PostQuery.php @@ -2,10 +2,21 @@ namespace Rareloop\Lumberjack; +use Illuminate\Support\Collection; use Spatie\Macroable\Macroable; use Timber\PostQuery as TimberPostQuery; class PostQuery extends TimberPostQuery { use Macroable; + + /** + * Get the posts in this query as a Collection. + * + * @return \Illuminate\Support\Collection + */ + public function toCollection(): Collection + { + return new Collection($this->getArrayCopy()); + } } diff --git a/tests/Unit/Http/Resolvers/AbstractContextResolverTest.php b/tests/Unit/Http/Resolvers/AbstractContextResolverTest.php new file mode 100644 index 00000000..6260b080 --- /dev/null +++ b/tests/Unit/Http/Resolvers/AbstractContextResolverTest.php @@ -0,0 +1,126 @@ +assertEmpty($resolver->getParameters($reflection, [], [])); + } + + #[Test] + public function it_ignores_classes_it_cannot_handle(): void + { + $resolver = new class extends AbstractContextResolver { + protected function canResolveClass(string $className): bool + { + return $className === \stdClass::class; + } + protected function resolveObject(string $className, mixed $context): mixed + { + return new \stdClass(); + } + }; + + $reflection = new ReflectionFunction(function (\Exception $e) { + }); + + $this->assertEmpty($resolver->getParameters($reflection, [], [])); + } + + #[Test] + public function it_throws_an_exception_if_context_is_missing_and_not_nullable(): void + { + Functions\expect('get_queried_object')->once()->andReturn(null); + + $resolver = new class extends AbstractContextResolver { + protected function canResolveClass(string $className): bool + { + return true; + } + protected function resolveObject(string $className, mixed $context): mixed + { + return new \stdClass(); + } + }; + + $reflection = new ReflectionFunction(function (\stdClass $obj) { + }); + + $this->expectException(\Rareloop\Lumberjack\Exceptions\MissingContextException::class); + $resolver->getParameters($reflection, [], []); + } + + #[Test] + public function it_resolves_to_null_if_context_is_missing_and_nullable(): void + { + Functions\expect('get_queried_object')->once()->andReturn(null); + + $resolver = new class extends AbstractContextResolver { + protected function canResolveClass(string $className): bool + { + return true; + } + protected function resolveObject(string $className, mixed $context): mixed + { + return new \stdClass(); + } + }; + + $reflection = new ReflectionFunction(function (?\stdClass $obj) { + }); + $resolved = $resolver->getParameters($reflection, [], []); + + $this->assertCount(1, $resolved); + $this->assertNull($resolved[0]); + } + + #[Test] + public function it_throws_an_exception_if_resolved_object_is_wrong_type(): void + { + Functions\expect('get_queried_object')->once()->andReturn(new \stdClass()); + + $resolver = new class extends AbstractContextResolver { + protected function canResolveClass(string $className): bool + { + return true; + } + protected function resolveObject(string $className, mixed $context): mixed + { + return new \Exception(); // Not a stdClass + } + }; + + $reflection = new ReflectionFunction(function (\stdClass $obj) { + }); + + $this->expectException(\Rareloop\Lumberjack\Exceptions\MismatchedContextException::class); + $resolver->getParameters($reflection, [], []); + } +} diff --git a/tests/Unit/Http/Resolvers/PostResolverTest.php b/tests/Unit/Http/Resolvers/PostResolverTest.php index 38481eeb..d2700109 100644 --- a/tests/Unit/Http/Resolvers/PostResolverTest.php +++ b/tests/Unit/Http/Resolvers/PostResolverTest.php @@ -6,7 +6,6 @@ use Mockery; use PHPUnit\Framework\Attributes\Test; use Rareloop\Lumberjack\Application; -use Rareloop\Lumberjack\Http\Resolvers\PostResolver; use Rareloop\Lumberjack\Providers\WordPressControllersServiceProvider; use Rareloop\Lumberjack\Test\TestCase; use Rareloop\Lumberjack\Test\Unit\Concerns\BrainMonkeyPHPUnitIntegration; @@ -37,7 +36,6 @@ protected function setUp(): void $config->shouldReceive('get')->with('app.resolvers', [])->andReturn([]); $this->app->bind('config', $config); - // Register the provider so the Invoker is set up with all resolvers $provider = new WordPressControllersServiceProvider($this->app); $provider->register(); @@ -87,58 +85,4 @@ public function handle(PostStub $post) $this->assertSame($postStub, $result); } - - #[Test] - public function it_throws_an_exception_if_the_queried_object_is_not_a_post(): void - { - Functions\expect('get_queried_object')->once()->andReturn(null); - - $controller = new class { - public function handle(Post $post) - { - } - }; - - $this->expectException(\Rareloop\Lumberjack\Exceptions\MissingContextException::class); - - $this->invoker->call([$controller, 'handle']); - } - - #[Test] - public function it_resolves_to_null_if_the_typehint_is_nullable_and_context_is_missing(): void - { - Functions\expect('get_queried_object')->once()->andReturn(null); - - $controller = new class { - public function handle(?Post $post) - { - return $post; - } - }; - - $result = $this->invoker->call([$controller, 'handle']); - - $this->assertNull($result); - } - - #[Test] - public function it_throws_an_exception_if_the_resolved_post_is_not_of_the_expected_typehinted_class(): void - { - $wpPost = Mockery::mock(WP_Post::class); - Functions\expect('get_queried_object')->once()->andReturn($wpPost); - - $timberPost = Mockery::mock(Post::class); // Not a PostStub - $timber = Mockery::mock('alias:' . Timber::class); - $timber->shouldReceive('get_post')->once()->with($wpPost)->andReturn($timberPost); - - $controller = new class { - public function handle(PostStub $post) - { - } - }; - - $this->expectException(\Rareloop\Lumberjack\Exceptions\MismatchedContextException::class); - - $this->invoker->call([$controller, 'handle']); - } } diff --git a/tests/Unit/Http/Resolvers/PostTypeResolverTest.php b/tests/Unit/Http/Resolvers/PostTypeResolverTest.php index 608a714c..417c65bd 100644 --- a/tests/Unit/Http/Resolvers/PostTypeResolverTest.php +++ b/tests/Unit/Http/Resolvers/PostTypeResolverTest.php @@ -139,4 +139,19 @@ public function handle(?LumberjackPostType $postType) $this->assertNull($result); } + + #[Test] + public function it_ignores_builtin_typehints(): void + { + $controller = new class { + public function handle(int $id) + { + return $id; + } + }; + + $result = $this->invoker->call([$controller, 'handle'], ['id' => 123]); + + $this->assertSame(123, $result); + } } diff --git a/tests/Unit/Http/Resolvers/TermResolverTest.php b/tests/Unit/Http/Resolvers/TermResolverTest.php index 54d8095b..7c2961cb 100644 --- a/tests/Unit/Http/Resolvers/TermResolverTest.php +++ b/tests/Unit/Http/Resolvers/TermResolverTest.php @@ -85,58 +85,4 @@ public function handle(TermStub $term) $this->assertSame($termStub, $result); } - - #[Test] - public function it_throws_an_exception_if_the_queried_object_is_not_a_term(): void - { - Functions\expect('get_queried_object')->once()->andReturn(null); - - $controller = new class { - public function handle(Term $term) - { - } - }; - - $this->expectException(\Rareloop\Lumberjack\Exceptions\MissingContextException::class); - - $this->invoker->call([$controller, 'handle']); - } - - #[Test] - public function it_resolves_to_null_if_the_typehint_is_nullable_and_context_is_missing(): void - { - Functions\expect('get_queried_object')->once()->andReturn(null); - - $controller = new class { - public function handle(?Term $term) - { - return $term; - } - }; - - $result = $this->invoker->call([$controller, 'handle']); - - $this->assertNull($result); - } - - #[Test] - public function it_throws_an_exception_if_the_resolved_term_is_not_of_the_expected_typehinted_class(): void - { - $wpTerm = Mockery::mock(WP_Term::class); - Functions\expect('get_queried_object')->once()->andReturn($wpTerm); - - $timberTerm = Mockery::mock(Term::class); // Not a TermStub - $timber = Mockery::mock('alias:' . Timber::class); - $timber->shouldReceive('get_term')->once()->with($wpTerm)->andReturn($timberTerm); - - $controller = new class { - public function handle(TermStub $term) - { - } - }; - - $this->expectException(\Rareloop\Lumberjack\Exceptions\MismatchedContextException::class); - - $this->invoker->call([$controller, 'handle']); - } } diff --git a/tests/Unit/Http/Resolvers/UserResolverTest.php b/tests/Unit/Http/Resolvers/UserResolverTest.php index 81af0de3..68058005 100644 --- a/tests/Unit/Http/Resolvers/UserResolverTest.php +++ b/tests/Unit/Http/Resolvers/UserResolverTest.php @@ -85,58 +85,4 @@ public function handle(UserStub $user) $this->assertSame($userStub, $result); } - - #[Test] - public function it_throws_an_exception_if_the_queried_object_is_not_a_user(): void - { - Functions\expect('get_queried_object')->once()->andReturn(null); - - $controller = new class { - public function handle(User $user) - { - } - }; - - $this->expectException(\Rareloop\Lumberjack\Exceptions\MissingContextException::class); - - $this->invoker->call([$controller, 'handle']); - } - - #[Test] - public function it_resolves_to_null_if_the_typehint_is_nullable_and_context_is_missing(): void - { - Functions\expect('get_queried_object')->once()->andReturn(null); - - $controller = new class { - public function handle(?User $user) - { - return $user; - } - }; - - $result = $this->invoker->call([$controller, 'handle']); - - $this->assertNull($result); - } - - #[Test] - public function it_throws_an_exception_if_the_resolved_user_is_not_of_the_expected_typehinted_class(): void - { - $wpUser = Mockery::mock(WP_User::class); - Functions\expect('get_queried_object')->once()->andReturn($wpUser); - - $timberUser = Mockery::mock(User::class); // Not a UserStub - $timber = Mockery::mock('alias:' . Timber::class); - $timber->shouldReceive('get_user')->once()->with($wpUser)->andReturn($timberUser); - - $controller = new class { - public function handle(UserStub $user) - { - } - }; - - $this->expectException(\Rareloop\Lumberjack\Exceptions\MismatchedContextException::class); - - $this->invoker->call([$controller, 'handle']); - } } diff --git a/tests/Unit/PostQueryTest.php b/tests/Unit/PostQueryTest.php index 96b7e934..6e01bc6e 100644 --- a/tests/Unit/PostQueryTest.php +++ b/tests/Unit/PostQueryTest.php @@ -26,6 +26,20 @@ public function it_extends_the_timber_post_query_class(): void $this->assertInstanceOf(TimberPostQuery::class, $postQuery); } + #[Test] + public function can_convert_to_collection(): void + { + $wpQuery = Mockery::mock(WP_Query::class); + $wpQuery->found_posts = 1; + $wpQuery->posts = [new \stdClass()]; + + $postQuery = new PostQuery($wpQuery); + $collection = $postQuery->toCollection(); + + $this->assertInstanceOf(\Illuminate\Support\Collection::class, $collection); + $this->assertCount(1, $collection); + } + #[Test] public function can_extend_post_query_with_macros(): void { From 2a261fca4f0d875909eaef64a8b1adea922a56bd Mon Sep 17 00:00:00 2001 From: Tom Mitchelmore Date: Mon, 18 May 2026 16:39:05 +0100 Subject: [PATCH 08/27] Address final review suggestions for DI refactor - Optimized WordPressControllersServiceProvider by defining core resolvers in priority order - Cleaned up AbstractContextResolverTest by using a shared test stub - Verified all DI resolver tests and linting --- .../WordPressControllersServiceProvider.php | 14 +++- .../Resolvers/AbstractContextResolverTest.php | 78 +++++++------------ 2 files changed, 37 insertions(+), 55 deletions(-) diff --git a/src/Providers/WordPressControllersServiceProvider.php b/src/Providers/WordPressControllersServiceProvider.php index 8c913d45..6b4781b8 100644 --- a/src/Providers/WordPressControllersServiceProvider.php +++ b/src/Providers/WordPressControllersServiceProvider.php @@ -26,15 +26,23 @@ public function register() $invoker = new Invoker($app); $resolverChain = $invoker->getParameterResolver(); - collect($app->get('config')->get('app.resolvers', [])) - ->merge($this->getCoreResolvers()) - ->reverse() + // 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; }); } + /** + * Get the core resolvers in the order they should be prepended. + * The last item in this list will have the highest priority among core resolvers, + * unless overridden by user-defined resolvers in config. + * + * @return array + */ protected function getCoreResolvers(): array { return [ diff --git a/tests/Unit/Http/Resolvers/AbstractContextResolverTest.php b/tests/Unit/Http/Resolvers/AbstractContextResolverTest.php index 6260b080..1cd53486 100644 --- a/tests/Unit/Http/Resolvers/AbstractContextResolverTest.php +++ b/tests/Unit/Http/Resolvers/AbstractContextResolverTest.php @@ -9,6 +9,7 @@ use Rareloop\Lumberjack\Test\TestCase; use Rareloop\Lumberjack\Test\Unit\Concerns\BrainMonkeyPHPUnitIntegration; use ReflectionFunction; +use ReflectionParameter; class AbstractContextResolverTest extends TestCase { @@ -17,16 +18,7 @@ class AbstractContextResolverTest extends TestCase #[Test] public function it_ignores_builtin_typehints(): void { - $resolver = new class extends AbstractContextResolver { - protected function canResolveClass(string $className): bool - { - return true; - } - protected function resolveObject(string $className, mixed $context): mixed - { - return new \stdClass(); - } - }; + $resolver = new TestContextResolver(); $reflection = new ReflectionFunction(function (int $id, string $name, $noType) { }); @@ -37,18 +29,10 @@ protected function resolveObject(string $className, mixed $context): mixed #[Test] public function it_ignores_classes_it_cannot_handle(): void { - $resolver = new class extends AbstractContextResolver { - protected function canResolveClass(string $className): bool - { - return $className === \stdClass::class; - } - protected function resolveObject(string $className, mixed $context): mixed - { - return new \stdClass(); - } - }; - - $reflection = new ReflectionFunction(function (\Exception $e) { + $resolver = new TestContextResolver(); + $resolver->canResolve = false; + + $reflection = new ReflectionFunction(function (\stdClass $obj) { }); $this->assertEmpty($resolver->getParameters($reflection, [], [])); @@ -59,16 +43,7 @@ public function it_throws_an_exception_if_context_is_missing_and_not_nullable(): { Functions\expect('get_queried_object')->once()->andReturn(null); - $resolver = new class extends AbstractContextResolver { - protected function canResolveClass(string $className): bool - { - return true; - } - protected function resolveObject(string $className, mixed $context): mixed - { - return new \stdClass(); - } - }; + $resolver = new TestContextResolver(); $reflection = new ReflectionFunction(function (\stdClass $obj) { }); @@ -82,16 +57,7 @@ public function it_resolves_to_null_if_context_is_missing_and_nullable(): void { Functions\expect('get_queried_object')->once()->andReturn(null); - $resolver = new class extends AbstractContextResolver { - protected function canResolveClass(string $className): bool - { - return true; - } - protected function resolveObject(string $className, mixed $context): mixed - { - return new \stdClass(); - } - }; + $resolver = new TestContextResolver(); $reflection = new ReflectionFunction(function (?\stdClass $obj) { }); @@ -106,16 +72,8 @@ public function it_throws_an_exception_if_resolved_object_is_wrong_type(): void { Functions\expect('get_queried_object')->once()->andReturn(new \stdClass()); - $resolver = new class extends AbstractContextResolver { - protected function canResolveClass(string $className): bool - { - return true; - } - protected function resolveObject(string $className, mixed $context): mixed - { - return new \Exception(); // Not a stdClass - } - }; + $resolver = new TestContextResolver(); + $resolver->resolvedObject = new \Exception(); // Not a stdClass $reflection = new ReflectionFunction(function (\stdClass $obj) { }); @@ -124,3 +82,19 @@ protected function resolveObject(string $className, mixed $context): mixed $resolver->getParameters($reflection, [], []); } } + +class TestContextResolver extends AbstractContextResolver +{ + public bool $canResolve = true; + public $resolvedObject; + + protected function canResolveClass(string $className): bool + { + return $this->canResolve; + } + + protected function resolveObject(string $className, mixed $context): mixed + { + return $this->resolvedObject ?? new \stdClass(); + } +} From accad990a5d82aafc021949395e4fafd3c7acd40 Mon Sep 17 00:00:00 2001 From: Tom Mitchelmore Date: Mon, 18 May 2026 16:45:44 +0100 Subject: [PATCH 09/27] Re-add comment --- src/Http/Responses/TimberResponse.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Http/Responses/TimberResponse.php b/src/Http/Responses/TimberResponse.php index ff13f7ef..b1150735 100644 --- a/src/Http/Responses/TimberResponse.php +++ b/src/Http/Responses/TimberResponse.php @@ -25,6 +25,9 @@ private function flattenContextToArrays(array|Arrayable|CollectionArrayable $con { $context = is_array($context) ? $context : $context->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) { if ($item instanceof Arrayable || $item instanceof CollectionArrayable) { $item = $this->flattenContextToArrays($item); From 54b92b1b2825d56ae01a89a92f2ec0a923108f91 Mon Sep 17 00:00:00 2001 From: Tom Mitchelmore Date: Mon, 18 May 2026 16:46:54 +0100 Subject: [PATCH 10/27] Move core resolvers --- .../WordPressControllersServiceProvider.php | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/src/Providers/WordPressControllersServiceProvider.php b/src/Providers/WordPressControllersServiceProvider.php index 6b4781b8..05155d29 100644 --- a/src/Providers/WordPressControllersServiceProvider.php +++ b/src/Providers/WordPressControllersServiceProvider.php @@ -36,13 +36,11 @@ public function register() }); } - /** - * Get the core resolvers in the order they should be prepended. - * The last item in this list will have the highest priority among core resolvers, - * unless overridden by user-defined resolvers in config. - * - * @return array - */ + public function boot() + { + add_filter('template_include', [$this, 'handleTemplateInclude']); + } + protected function getCoreResolvers(): array { return [ @@ -54,11 +52,6 @@ protected function getCoreResolvers(): array ]; } - public function boot() - { - add_filter('template_include', [$this, 'handleTemplateInclude']); - } - public function handleTemplateInclude($template) { include $template; From 6a9a14ffc01c02a163e703bb54af5df76b781f02 Mon Sep 17 00:00:00 2001 From: Tom Mitchelmore Date: Tue, 19 May 2026 09:32:49 +0100 Subject: [PATCH 11/27] Build controllers with DI --- src/Application.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Application.php b/src/Application.php index 0f41a494..08aeb7c5 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')) { From 4cdf3395776ad1c9ffe7914785b5874b05257ba1 Mon Sep 17 00:00:00 2001 From: Tom Mitchelmore Date: Tue, 19 May 2026 09:33:18 +0100 Subject: [PATCH 12/27] Ensure that Timber context has array access methods --- src/Http/TimberContext.php | 22 ++++++++++++++++++++++ tests/Unit/Http/TimberContextTest.php | 18 ++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/src/Http/TimberContext.php b/src/Http/TimberContext.php index ecd53bbe..ac3d66cd 100644 --- a/src/Http/TimberContext.php +++ b/src/Http/TimberContext.php @@ -48,4 +48,26 @@ 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/tests/Unit/Http/TimberContextTest.php b/tests/Unit/Http/TimberContextTest.php index 82709adb..54622218 100644 --- a/tests/Unit/Http/TimberContextTest.php +++ b/tests/Unit/Http/TimberContextTest.php @@ -35,4 +35,22 @@ public function get_returns_default_if_key_does_not_exist(): void $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')); + } } From 63d2560f7dd358058ed86423b31944a8aa8c115d Mon Sep 17 00:00:00 2001 From: Tom Mitchelmore Date: Tue, 19 May 2026 11:14:10 +0100 Subject: [PATCH 13/27] Add static class resolution parity for Term and User proxy objects - Implemented Term::termClass() to resolve mapped taxonomy classes - Implemented User::userClass() to resolve mapped user classes via filters - Added comprehensive unit tests and verified 100% coverage on new methods --- src/Http/Resolvers/PostResolver.php | 4 +++- src/Term.php | 17 +++++++++++++++++ src/User.php | 11 +++++++++++ tests/Unit/TermTest.php | 15 +++++++++++++++ tests/Unit/UserTest.php | 14 ++++++++++++++ 5 files changed, 60 insertions(+), 1 deletion(-) diff --git a/src/Http/Resolvers/PostResolver.php b/src/Http/Resolvers/PostResolver.php index ad6d3208..360b624f 100644 --- a/src/Http/Resolvers/PostResolver.php +++ b/src/Http/Resolvers/PostResolver.php @@ -3,6 +3,7 @@ namespace Rareloop\Lumberjack\Http\Resolvers; use Rareloop\Lumberjack\Post; +use Timber\CoreEntityInterface; use Timber\Post as TimberPost; use Timber\Timber; @@ -10,7 +11,8 @@ class PostResolver extends AbstractContextResolver { protected function canResolveClass(string $className): bool { - return is_a($className, Post::class, true) || is_a($className, TimberPost::class, true); + return is_a($className, Post::class, true) + || is_a($className, TimberPost::class, true); } protected function resolveObject(string $className, mixed $context): mixed diff --git a/src/Term.php b/src/Term.php index 4856c738..84f79460 100644 --- a/src/Term.php +++ b/src/Term.php @@ -2,10 +2,27 @@ namespace Rareloop\Lumberjack; +use Illuminate\Support\Arr; use Spatie\Macroable\Macroable; use Timber\Term as TimberTerm; class Term extends TimberTerm { use Macroable; + + /** + * Get the Term class associated with a taxonomy slug. + * + * @param string $taxonomy + * @return string|null + */ + public static function termClass(string $taxonomy): ?string + { + $classMap = apply_filters('timber/term/classmap', [ + 'post_tag' => Term::class, + 'category' => Term::class, + ]); + + return Arr::get($classMap, $taxonomy); + } } diff --git a/src/User.php b/src/User.php index 32d55acf..2d52619c 100644 --- a/src/User.php +++ b/src/User.php @@ -8,4 +8,15 @@ class User extends TimberUser { use Macroable; + + /** + * Get the User class associated with a user object. + * + * @param \WP_User|null $user + * @return string + */ + public static function userClass(?\WP_User $user = null): string + { + return apply_filters('timber/user/class', User::class, $user); + } } diff --git a/tests/Unit/TermTest.php b/tests/Unit/TermTest.php index 3bf377cd..06f66508 100644 --- a/tests/Unit/TermTest.php +++ b/tests/Unit/TermTest.php @@ -32,6 +32,21 @@ public function can_extend_term_with_macros(): void $this->assertSame('macro_result', $term->testMacro()); } + + #[Test] + public function can_get_term_class_from_classmap(): void + { + \Brain\Monkey\Filters\expectApplied('timber/term/classmap') + ->times(3) + ->andReturn([ + 'category' => Term::class, + 'book_cat' => 'App\Term\BookCategory', + ]); + + $this->assertSame(Term::class, Term::termClass('category')); + $this->assertSame('App\Term\BookCategory', Term::termClass('book_cat')); + $this->assertNull(Term::termClass('missing')); + } } class TestableTerm extends Term diff --git a/tests/Unit/UserTest.php b/tests/Unit/UserTest.php index 98938629..1ed7a5e9 100644 --- a/tests/Unit/UserTest.php +++ b/tests/Unit/UserTest.php @@ -7,6 +7,7 @@ use Timber\User as TimberUser; use Rareloop\Lumberjack\Test\TestCase; use Rareloop\Lumberjack\Test\Unit\Concerns\BrainMonkeyPHPUnitIntegration; +use Mockery; class UserTest extends TestCase { @@ -32,6 +33,19 @@ public function can_extend_user_with_macros(): void $this->assertSame('macro_result', $user->testMacro()); } + + #[Test] + public function can_get_user_class_from_filter(): void + { + $wpUser = Mockery::mock(\WP_User::class); + + \Brain\Monkey\Filters\expectApplied('timber/user/class') + ->once() + ->with(User::class, $wpUser) + ->andReturn('App\User\Administrator'); + + $this->assertSame('App\User\Administrator', User::userClass($wpUser)); + } } class TestableUser extends User From fbe44c013b41be73413183287b2f489783fd3353 Mon Sep 17 00:00:00 2001 From: Tom Mitchelmore Date: Tue, 19 May 2026 14:52:21 +0100 Subject: [PATCH 14/27] Simplify if statements --- src/Http/Resolvers/PostTypeResolver.php | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/Http/Resolvers/PostTypeResolver.php b/src/Http/Resolvers/PostTypeResolver.php index 692d2eae..8ccf76bb 100644 --- a/src/Http/Resolvers/PostTypeResolver.php +++ b/src/Http/Resolvers/PostTypeResolver.php @@ -18,13 +18,11 @@ protected function resolveObject(string $className, mixed $context): mixed return new PostType($context->name); } - if (is_a($context, 'WP_Post')) { - if (function_exists('get_post_type_object')) { - $postTypeObject = get_post_type_object($context->post_type); + if (is_a($context, 'WP_Post') && function_exists('get_post_type_object')) { + $postTypeObject = get_post_type_object($context->post_type); - if ($postTypeObject) { - return new PostType($postTypeObject->name); - } + if ($postTypeObject) { + return new PostType($postTypeObject->name); } } From bff4f3bd2d6831dc16aedef76e05211775d5098e Mon Sep 17 00:00:00 2001 From: Tom Mitchelmore Date: Tue, 19 May 2026 15:23:33 +0100 Subject: [PATCH 15/27] Remove unused import --- src/Http/Resolvers/PostResolver.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Http/Resolvers/PostResolver.php b/src/Http/Resolvers/PostResolver.php index 360b624f..17884f38 100644 --- a/src/Http/Resolvers/PostResolver.php +++ b/src/Http/Resolvers/PostResolver.php @@ -3,7 +3,6 @@ namespace Rareloop\Lumberjack\Http\Resolvers; use Rareloop\Lumberjack\Post; -use Timber\CoreEntityInterface; use Timber\Post as TimberPost; use Timber\Timber; From 9c27db8c524ea7b5a598287681047f73b1bc2870 Mon Sep 17 00:00:00 2001 From: Tom Mitchelmore Date: Tue, 19 May 2026 15:26:01 +0100 Subject: [PATCH 16/27] Remove from gitignore --- .gitignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitignore b/.gitignore index 7bc6b636..e24d6c76 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,3 @@ composer.lock /tests/Unit/logs .phpunit.result.cache .phpunit.cache -coverage_report From 344e757d6e12758715fed550a4d384ffd79ab634 Mon Sep 17 00:00:00 2001 From: Tom Mitchelmore Date: Tue, 19 May 2026 15:28:00 +0100 Subject: [PATCH 17/27] Remove reflection --- .../Resolvers/AbstractContextResolverTest.php | 84 +++++++++++++------ 1 file changed, 57 insertions(+), 27 deletions(-) diff --git a/tests/Unit/Http/Resolvers/AbstractContextResolverTest.php b/tests/Unit/Http/Resolvers/AbstractContextResolverTest.php index 1cd53486..39aa57e6 100644 --- a/tests/Unit/Http/Resolvers/AbstractContextResolverTest.php +++ b/tests/Unit/Http/Resolvers/AbstractContextResolverTest.php @@ -5,37 +5,62 @@ use Brain\Monkey\Functions; use Mockery; use PHPUnit\Framework\Attributes\Test; +use Rareloop\Lumberjack\Application; use Rareloop\Lumberjack\Http\Resolvers\AbstractContextResolver; use Rareloop\Lumberjack\Test\TestCase; use Rareloop\Lumberjack\Test\Unit\Concerns\BrainMonkeyPHPUnitIntegration; -use ReflectionFunction; -use ReflectionParameter; +use Rareloop\Router\Invoker; class AbstractContextResolverTest extends TestCase { use BrainMonkeyPHPUnitIntegration; + private Application $app; + private TestContextResolver $resolver; + private Invoker $invoker; + + protected function setUp(): void + { + parent::setUp(); + + $this->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 { - $resolver = new TestContextResolver(); + $controller = new class { + public function handle(int $id, string $name, $noType) + { + return $id; + } + }; - $reflection = new ReflectionFunction(function (int $id, string $name, $noType) { - }); + $result = $this->invoker->call([$controller, 'handle'], ['id' => 123, 'name' => 'foo', 'noType' => 'bar']); - $this->assertEmpty($resolver->getParameters($reflection, [], [])); + $this->assertSame(123, $result); } #[Test] public function it_ignores_classes_it_cannot_handle(): void { - $resolver = new TestContextResolver(); - $resolver->canResolve = false; + $this->resolver->canResolve = false; - $reflection = new ReflectionFunction(function (\stdClass $obj) { - }); + $controller = new class { + public function handle(Application $app) + { + return $app; + } + }; - $this->assertEmpty($resolver->getParameters($reflection, [], [])); + $result = $this->invoker->call([$controller, 'handle']); + + $this->assertSame($this->app, $result); } #[Test] @@ -43,13 +68,14 @@ public function it_throws_an_exception_if_context_is_missing_and_not_nullable(): { Functions\expect('get_queried_object')->once()->andReturn(null); - $resolver = new TestContextResolver(); - - $reflection = new ReflectionFunction(function (\stdClass $obj) { - }); + $controller = new class { + public function handle(\stdClass $obj) + { + } + }; $this->expectException(\Rareloop\Lumberjack\Exceptions\MissingContextException::class); - $resolver->getParameters($reflection, [], []); + $this->invoker->call([$controller, 'handle']); } #[Test] @@ -57,14 +83,16 @@ public function it_resolves_to_null_if_context_is_missing_and_nullable(): void { Functions\expect('get_queried_object')->once()->andReturn(null); - $resolver = new TestContextResolver(); + $controller = new class { + public function handle(?\stdClass $obj) + { + return $obj; + } + }; - $reflection = new ReflectionFunction(function (?\stdClass $obj) { - }); - $resolved = $resolver->getParameters($reflection, [], []); + $result = $this->invoker->call([$controller, 'handle']); - $this->assertCount(1, $resolved); - $this->assertNull($resolved[0]); + $this->assertNull($result); } #[Test] @@ -72,14 +100,16 @@ public function it_throws_an_exception_if_resolved_object_is_wrong_type(): void { Functions\expect('get_queried_object')->once()->andReturn(new \stdClass()); - $resolver = new TestContextResolver(); - $resolver->resolvedObject = new \Exception(); // Not a stdClass + $this->resolver->resolvedObject = new \Exception(); // Not a stdClass - $reflection = new ReflectionFunction(function (\stdClass $obj) { - }); + $controller = new class { + public function handle(\stdClass $obj) + { + } + }; $this->expectException(\Rareloop\Lumberjack\Exceptions\MismatchedContextException::class); - $resolver->getParameters($reflection, [], []); + $this->invoker->call([$controller, 'handle']); } } From d9ee4c7114807b40ed1dd7a367b8c49c4dfec490 Mon Sep 17 00:00:00 2001 From: Tom Mitchelmore Date: Tue, 19 May 2026 16:55:43 +0100 Subject: [PATCH 18/27] Revert controller change --- src/Http/Controller.php | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/src/Http/Controller.php b/src/Http/Controller.php index 3afd14f0..17befb41 100644 --- a/src/Http/Controller.php +++ b/src/Http/Controller.php @@ -2,23 +2,8 @@ namespace Rareloop\Lumberjack\Http; -use Rareloop\Lumberjack\Helpers; -use Rareloop\Lumberjack\Http\Responses\TimberResponse; use Rareloop\Router\Controller as BaseController; class Controller extends BaseController { - /** - * Return a new TimberResponse from the controller. - * - * @param string $template - * @param array|\Illuminate\Contracts\Support\Arrayable $context - * @param integer $status - * @param array $headers - * @return \Rareloop\Lumberjack\Http\Responses\TimberResponse - */ - public function view(string $template, $context = [], int $status = 200, array $headers = []) - { - return Helpers::view($template, $context, $status, $headers); - } } From 16158d5cb0ef3910b6a5d76ce3cb7c8e654206c5 Mon Sep 17 00:00:00 2001 From: Tom Mitchelmore Date: Tue, 19 May 2026 16:58:26 +0100 Subject: [PATCH 19/27] Remove TimberResponse Bind --- src/Providers/TimberServiceProvider.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Providers/TimberServiceProvider.php b/src/Providers/TimberServiceProvider.php index 2a1c269f..40500eeb 100644 --- a/src/Providers/TimberServiceProvider.php +++ b/src/Providers/TimberServiceProvider.php @@ -14,8 +14,6 @@ public function register() Timber::init(); $this->app->singleton(TimberContext::class, fn() => new TimberContext(Timber::context())); - - $this->app->bind(TimberResponse::class, TimberResponse::class); } public function boot(Config $config) From f24b6e7f83b58bba8566c7425cd3c762460d7107 Mon Sep 17 00:00:00 2001 From: Tom Mitchelmore Date: Tue, 19 May 2026 17:03:50 +0100 Subject: [PATCH 20/27] Add `isValidContext` methods --- src/Exceptions/MismatchedContextException.php | 6 +- .../Resolvers/AbstractContextResolver.php | 19 +++- src/Http/Resolvers/PostQueryResolver.php | 7 +- src/Http/Resolvers/PostResolver.php | 6 ++ src/Http/Resolvers/PostTypeResolver.php | 5 + src/Http/Resolvers/TermResolver.php | 6 ++ src/Http/Resolvers/UserResolver.php | 6 ++ .../Resolvers/AbstractContextResolverTest.php | 6 ++ .../Resolvers/NullableMultiParameterTest.php | 91 +++++++++++++++++++ 9 files changed, 145 insertions(+), 7 deletions(-) create mode 100644 tests/Unit/Http/Resolvers/NullableMultiParameterTest.php diff --git a/src/Exceptions/MismatchedContextException.php b/src/Exceptions/MismatchedContextException.php index 4db78e8c..13ea170e 100644 --- a/src/Exceptions/MismatchedContextException.php +++ b/src/Exceptions/MismatchedContextException.php @@ -4,12 +4,12 @@ class MismatchedContextException extends UnresolvableContextException { - public static function forIncorrectClass(string $expectedClass, object $actualTimberObject): self + public static function forIncorrectClass(string $expectedClass, mixed $actualValue): self { - $actualClass = $actualTimberObject::class; + $actualType = is_object($actualValue) ? $actualValue::class : gettype($actualValue); return new static( - "Resolved a WordPress object, but it was of type [{$actualClass}] " . + "Resolved a WordPress object, but it was of type [{$actualType}] " . "instead of the expected [{$expectedClass}]." ); } diff --git a/src/Http/Resolvers/AbstractContextResolver.php b/src/Http/Resolvers/AbstractContextResolver.php index bc3358c0..6b618def 100644 --- a/src/Http/Resolvers/AbstractContextResolver.php +++ b/src/Http/Resolvers/AbstractContextResolver.php @@ -39,15 +39,19 @@ public function getParameters( throw MissingContextException::forType($className, $context); } + if (!$this->isValidContext($context, $className)) { + throw MismatchedContextException::forIncorrectClass($className, $context); + } + $resolvedObject = $this->resolveObject($className, $context); - if (!$resolvedObject instanceof $className) { + if (!is_null($resolvedObject) && !$resolvedObject instanceof $className) { throw MismatchedContextException::forIncorrectClass($className, $resolvedObject); } $resolvedParameters[$parameter->getPosition()] = $resolvedObject; - } catch (MissingContextException $e) { - // If the context is entirely missing, we allow null if the typehint supports it + } 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; } @@ -78,6 +82,15 @@ protected function getContext(): mixed */ 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. * diff --git a/src/Http/Resolvers/PostQueryResolver.php b/src/Http/Resolvers/PostQueryResolver.php index cb5f9aed..ae5d4c80 100644 --- a/src/Http/Resolvers/PostQueryResolver.php +++ b/src/Http/Resolvers/PostQueryResolver.php @@ -21,9 +21,14 @@ protected function canResolveClass(string $className): bool || is_subclass_of($className, TimberPostQuery::class); } + protected function isValidContext(mixed $context, string $className): bool + { + return is_a($context, 'WP_Query'); + } + protected function getContext(): mixed { - return $this->app->get(WP_Query::class); + return $this->app->get('WP_Query'); } protected function resolveObject(string $className, mixed $context): mixed diff --git a/src/Http/Resolvers/PostResolver.php b/src/Http/Resolvers/PostResolver.php index 17884f38..2b4fcd8f 100644 --- a/src/Http/Resolvers/PostResolver.php +++ b/src/Http/Resolvers/PostResolver.php @@ -5,6 +5,7 @@ use Rareloop\Lumberjack\Post; use Timber\Post as TimberPost; use Timber\Timber; +use Timber\CoreEntityInterface; class PostResolver extends AbstractContextResolver { @@ -14,6 +15,11 @@ protected function canResolveClass(string $className): bool || is_a($className, TimberPost::class, true); } + protected function isValidContext(mixed $context, string $className): bool + { + return is_a($context, 'WP_Post') || is_a($context, CoreEntityInterface::class); + } + protected function resolveObject(string $className, mixed $context): mixed { return Timber::get_post($context); diff --git a/src/Http/Resolvers/PostTypeResolver.php b/src/Http/Resolvers/PostTypeResolver.php index 8ccf76bb..654a13ae 100644 --- a/src/Http/Resolvers/PostTypeResolver.php +++ b/src/Http/Resolvers/PostTypeResolver.php @@ -12,6 +12,11 @@ protected function canResolveClass(string $className): bool return is_a($className, PostType::class, true) || is_a($className, TimberPostType::class, true); } + protected function isValidContext(mixed $context, string $className): bool + { + return is_a($context, 'WP_Post_Type') || is_a($context, 'WP_Post'); + } + protected function resolveObject(string $className, mixed $context): mixed { if (is_a($context, 'WP_Post_Type')) { diff --git a/src/Http/Resolvers/TermResolver.php b/src/Http/Resolvers/TermResolver.php index 95e173ef..6bc77e79 100644 --- a/src/Http/Resolvers/TermResolver.php +++ b/src/Http/Resolvers/TermResolver.php @@ -5,6 +5,7 @@ use Rareloop\Lumberjack\Term; use Timber\Term as TimberTerm; use Timber\Timber; +use Timber\CoreEntityInterface; class TermResolver extends AbstractContextResolver { @@ -13,6 +14,11 @@ protected function canResolveClass(string $className): bool return is_a($className, Term::class, true) || is_a($className, TimberTerm::class, true); } + protected function isValidContext(mixed $context, string $className): bool + { + return is_a($context, 'WP_Term') || is_a($context, CoreEntityInterface::class); + } + protected function resolveObject(string $className, mixed $context): mixed { return Timber::get_term($context); diff --git a/src/Http/Resolvers/UserResolver.php b/src/Http/Resolvers/UserResolver.php index 29f69ae2..d3f711db 100644 --- a/src/Http/Resolvers/UserResolver.php +++ b/src/Http/Resolvers/UserResolver.php @@ -5,6 +5,7 @@ use Rareloop\Lumberjack\User; use Timber\User as TimberUser; use Timber\Timber; +use Timber\CoreEntityInterface; class UserResolver extends AbstractContextResolver { @@ -13,6 +14,11 @@ protected function canResolveClass(string $className): bool return is_a($className, User::class, true) || is_a($className, TimberUser::class, true); } + protected function isValidContext(mixed $context, string $className): bool + { + return is_a($context, 'WP_User') || is_a($context, CoreEntityInterface::class); + } + protected function resolveObject(string $className, mixed $context): mixed { return Timber::get_user($context); diff --git a/tests/Unit/Http/Resolvers/AbstractContextResolverTest.php b/tests/Unit/Http/Resolvers/AbstractContextResolverTest.php index 39aa57e6..1cc3b3a6 100644 --- a/tests/Unit/Http/Resolvers/AbstractContextResolverTest.php +++ b/tests/Unit/Http/Resolvers/AbstractContextResolverTest.php @@ -116,6 +116,7 @@ public function handle(\stdClass $obj) class TestContextResolver extends AbstractContextResolver { public bool $canResolve = true; + public bool $isValid = true; public $resolvedObject; protected function canResolveClass(string $className): bool @@ -123,6 +124,11 @@ 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 00000000..a31e48a6 --- /dev/null +++ b/tests/Unit/Http/Resolvers/NullableMultiParameterTest.php @@ -0,0 +1,91 @@ +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']); + } +} From c1a77e40f1c6df2ac1adb261cb6b06027c58f610 Mon Sep 17 00:00:00 2001 From: Tom Mitchelmore Date: Tue, 19 May 2026 17:17:10 +0100 Subject: [PATCH 21/27] Remove ControllerTest --- tests/Unit/Http/ControllerTest.php | 35 ------------------------------ 1 file changed, 35 deletions(-) delete mode 100644 tests/Unit/Http/ControllerTest.php diff --git a/tests/Unit/Http/ControllerTest.php b/tests/Unit/Http/ControllerTest.php deleted file mode 100644 index 71373d64..00000000 --- a/tests/Unit/Http/ControllerTest.php +++ /dev/null @@ -1,35 +0,0 @@ -shouldReceive('compile')->once()->andReturn('testing123'); - - $controller = new Controller(); - $response = $controller->view('template.twig', ['foo' => 'bar'], 201, ['X-Header' => 'value']); - - $this->assertInstanceOf(TimberResponse::class, $response); - $this->assertSame(201, $response->getStatusCode()); - $this->assertSame('value', $response->getHeader('X-Header')[0]); - } -} From be3ebbb09d9c723f709506fe4dca5cb04507ccc3 Mon Sep 17 00:00:00 2001 From: Tom Mitchelmore Date: Wed, 20 May 2026 11:15:21 +0100 Subject: [PATCH 22/27] Update Timber import --- src/Http/Resolvers/PostQueryResolver.php | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/Http/Resolvers/PostQueryResolver.php b/src/Http/Resolvers/PostQueryResolver.php index ae5d4c80..ff622d34 100644 --- a/src/Http/Resolvers/PostQueryResolver.php +++ b/src/Http/Resolvers/PostQueryResolver.php @@ -3,22 +3,20 @@ namespace Rareloop\Lumberjack\Http\Resolvers; use Rareloop\Lumberjack\Application; -use Timber\PostQuery as TimberPostQuery; +use Timber\PostQuery; use Timber\PostCollectionInterface; use Timber\Timber; use WP_Query; class PostQueryResolver extends AbstractContextResolver { - public function __construct(protected Application $app) - { - } + public function __construct(protected Application $app) {} protected function canResolveClass(string $className): bool { - return $className === TimberPostQuery::class + return $className === PostQuery::class || $className === PostCollectionInterface::class - || is_subclass_of($className, TimberPostQuery::class); + || is_subclass_of($className, PostQuery::class); } protected function isValidContext(mixed $context, string $className): bool @@ -34,7 +32,7 @@ protected function getContext(): mixed 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) { + if ($className === PostCollectionInterface::class || $className === PostQuery::class) { return Timber::get_posts($context); } From fbf376c72eb6709d286c72d99f0c2c6adb94f3ff Mon Sep 17 00:00:00 2001 From: Tom Mitchelmore Date: Wed, 20 May 2026 11:17:30 +0100 Subject: [PATCH 23/27] Change getCoreResolvers visibility --- .../WordPressControllersServiceProvider.php | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/Providers/WordPressControllersServiceProvider.php b/src/Providers/WordPressControllersServiceProvider.php index 05155d29..817a4088 100644 --- a/src/Providers/WordPressControllersServiceProvider.php +++ b/src/Providers/WordPressControllersServiceProvider.php @@ -41,17 +41,6 @@ public function boot() add_filter('template_include', [$this, 'handleTemplateInclude']); } - protected function getCoreResolvers(): array - { - return [ - PostTypeResolver::class, - PostQueryResolver::class, - PostResolver::class, - TermResolver::class, - UserResolver::class, - ]; - } - public function handleTemplateInclude($template) { include $template; @@ -144,4 +133,15 @@ private function createDispatcher(array $middlewares): Dispatcher return new Dispatcher($middlewares, $resolver); } + + private function getCoreResolvers(): array + { + return [ + PostTypeResolver::class, + PostQueryResolver::class, + PostResolver::class, + TermResolver::class, + UserResolver::class, + ]; + } } From ec23d81023ebc11e9888fa1420ab0b23367bf837 Mon Sep 17 00:00:00 2001 From: Tom Mitchelmore Date: Wed, 20 May 2026 11:23:36 +0100 Subject: [PATCH 24/27] Remove toCollection from PostQuery --- src/PostQuery.php | 10 ---------- tests/Unit/PostQueryTest.php | 14 -------------- 2 files changed, 24 deletions(-) diff --git a/src/PostQuery.php b/src/PostQuery.php index 9083cb25..275ccd65 100644 --- a/src/PostQuery.php +++ b/src/PostQuery.php @@ -9,14 +9,4 @@ class PostQuery extends TimberPostQuery { use Macroable; - - /** - * Get the posts in this query as a Collection. - * - * @return \Illuminate\Support\Collection - */ - public function toCollection(): Collection - { - return new Collection($this->getArrayCopy()); - } } diff --git a/tests/Unit/PostQueryTest.php b/tests/Unit/PostQueryTest.php index 6e01bc6e..96b7e934 100644 --- a/tests/Unit/PostQueryTest.php +++ b/tests/Unit/PostQueryTest.php @@ -26,20 +26,6 @@ public function it_extends_the_timber_post_query_class(): void $this->assertInstanceOf(TimberPostQuery::class, $postQuery); } - #[Test] - public function can_convert_to_collection(): void - { - $wpQuery = Mockery::mock(WP_Query::class); - $wpQuery->found_posts = 1; - $wpQuery->posts = [new \stdClass()]; - - $postQuery = new PostQuery($wpQuery); - $collection = $postQuery->toCollection(); - - $this->assertInstanceOf(\Illuminate\Support\Collection::class, $collection); - $this->assertCount(1, $collection); - } - #[Test] public function can_extend_post_query_with_macros(): void { From 1858617f425900c65f8eeacc07b8ec04a90d8937 Mon Sep 17 00:00:00 2001 From: Tom Mitchelmore Date: Wed, 20 May 2026 12:07:07 +0100 Subject: [PATCH 25/27] Apply fixes from comments --- src/Http/Resolvers/PostQueryResolver.php | 17 +++---- src/Http/Resolvers/PostResolver.php | 3 +- src/Http/Resolvers/PostTypeResolver.php | 8 ++-- src/Http/Resolvers/TermResolver.php | 3 +- src/Http/Resolvers/UserResolver.php | 3 +- src/Providers/TimberServiceProvider.php | 1 - src/Term.php | 16 ------- src/User.php | 11 ----- .../Resolvers/AbstractContextResolverTest.php | 21 +++++---- .../Resolvers/NullableMultiParameterTest.php | 21 ++++----- .../Http/Resolvers/PostQueryResolverTest.php | 12 ++--- .../Unit/Http/Resolvers/PostResolverTest.php | 3 +- .../Http/Resolvers/PostTypeResolverTest.php | 13 +++--- .../Unit/Http/Resolvers/TermResolverTest.php | 3 +- .../Unit/Http/Resolvers/UserResolverTest.php | 3 +- .../Http/Responses/TimberResponseTest.php | 18 ++++++++ tests/Unit/PostTest.php | 10 +++-- tests/Unit/PostTypeTest.php | 8 ++-- tests/Unit/TermTest.php | 45 +++---------------- tests/Unit/UserTest.php | 44 +++--------------- 20 files changed, 107 insertions(+), 156 deletions(-) diff --git a/src/Http/Resolvers/PostQueryResolver.php b/src/Http/Resolvers/PostQueryResolver.php index ff622d34..0411c3fe 100644 --- a/src/Http/Resolvers/PostQueryResolver.php +++ b/src/Http/Resolvers/PostQueryResolver.php @@ -3,36 +3,37 @@ namespace Rareloop\Lumberjack\Http\Resolvers; use Rareloop\Lumberjack\Application; -use Timber\PostQuery; +use Timber\PostQuery as TimberPostQuery; use Timber\PostCollectionInterface; use Timber\Timber; use WP_Query; class PostQueryResolver extends AbstractContextResolver { - public function __construct(protected Application $app) {} + public function __construct(protected Application $app) + { + } protected function canResolveClass(string $className): bool { - return $className === PostQuery::class - || $className === PostCollectionInterface::class - || is_subclass_of($className, PostQuery::class); + return is_a($className, TimberPostQuery::class, true) + || is_a($className, PostCollectionInterface::class, true); } protected function isValidContext(mixed $context, string $className): bool { - return is_a($context, 'WP_Query'); + return is_a($context, WP_Query::class); } protected function getContext(): mixed { - return $this->app->get('WP_Query'); + return $this->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 === PostQuery::class) { + if ($className === PostCollectionInterface::class || $className === TimberPostQuery::class) { return Timber::get_posts($context); } diff --git a/src/Http/Resolvers/PostResolver.php b/src/Http/Resolvers/PostResolver.php index 2b4fcd8f..e69cf9ac 100644 --- a/src/Http/Resolvers/PostResolver.php +++ b/src/Http/Resolvers/PostResolver.php @@ -6,6 +6,7 @@ use Timber\Post as TimberPost; use Timber\Timber; use Timber\CoreEntityInterface; +use WP_Post; class PostResolver extends AbstractContextResolver { @@ -17,7 +18,7 @@ protected function canResolveClass(string $className): bool protected function isValidContext(mixed $context, string $className): bool { - return is_a($context, 'WP_Post') || is_a($context, CoreEntityInterface::class); + return is_a($context, WP_Post::class) || is_a($context, CoreEntityInterface::class); } protected function resolveObject(string $className, mixed $context): mixed diff --git a/src/Http/Resolvers/PostTypeResolver.php b/src/Http/Resolvers/PostTypeResolver.php index 654a13ae..e79fb070 100644 --- a/src/Http/Resolvers/PostTypeResolver.php +++ b/src/Http/Resolvers/PostTypeResolver.php @@ -4,6 +4,8 @@ use Rareloop\Lumberjack\PostType; use Timber\PostType as TimberPostType; +use WP_Post; +use WP_Post_Type; class PostTypeResolver extends AbstractContextResolver { @@ -14,16 +16,16 @@ protected function canResolveClass(string $className): bool protected function isValidContext(mixed $context, string $className): bool { - return is_a($context, 'WP_Post_Type') || is_a($context, 'WP_Post'); + return is_a($context, WP_Post_Type::class) || is_a($context, WP_Post::class); } protected function resolveObject(string $className, mixed $context): mixed { - if (is_a($context, 'WP_Post_Type')) { + if (is_a($context, WP_Post_Type::class)) { return new PostType($context->name); } - if (is_a($context, 'WP_Post') && function_exists('get_post_type_object')) { + if (is_a($context, WP_Post::class) && function_exists('get_post_type_object')) { $postTypeObject = get_post_type_object($context->post_type); if ($postTypeObject) { diff --git a/src/Http/Resolvers/TermResolver.php b/src/Http/Resolvers/TermResolver.php index 6bc77e79..eaa42949 100644 --- a/src/Http/Resolvers/TermResolver.php +++ b/src/Http/Resolvers/TermResolver.php @@ -6,6 +6,7 @@ use Timber\Term as TimberTerm; use Timber\Timber; use Timber\CoreEntityInterface; +use WP_Term; class TermResolver extends AbstractContextResolver { @@ -16,7 +17,7 @@ protected function canResolveClass(string $className): bool protected function isValidContext(mixed $context, string $className): bool { - return is_a($context, 'WP_Term') || is_a($context, CoreEntityInterface::class); + return is_a($context, WP_Term::class) || is_a($context, CoreEntityInterface::class); } protected function resolveObject(string $className, mixed $context): mixed diff --git a/src/Http/Resolvers/UserResolver.php b/src/Http/Resolvers/UserResolver.php index d3f711db..7d25a960 100644 --- a/src/Http/Resolvers/UserResolver.php +++ b/src/Http/Resolvers/UserResolver.php @@ -6,6 +6,7 @@ use Timber\User as TimberUser; use Timber\Timber; use Timber\CoreEntityInterface; +use WP_User; class UserResolver extends AbstractContextResolver { @@ -16,7 +17,7 @@ protected function canResolveClass(string $className): bool protected function isValidContext(mixed $context, string $className): bool { - return is_a($context, 'WP_User') || is_a($context, CoreEntityInterface::class); + return is_a($context, WP_User::class) || is_a($context, CoreEntityInterface::class); } protected function resolveObject(string $className, mixed $context): mixed diff --git a/src/Providers/TimberServiceProvider.php b/src/Providers/TimberServiceProvider.php index 40500eeb..5a174b1f 100644 --- a/src/Providers/TimberServiceProvider.php +++ b/src/Providers/TimberServiceProvider.php @@ -5,7 +5,6 @@ use Rareloop\Lumberjack\Config; use Rareloop\Lumberjack\Http\TimberContext; use Timber\Timber; -use Rareloop\Lumberjack\Http\Responses\TimberResponse; class TimberServiceProvider extends ServiceProvider { diff --git a/src/Term.php b/src/Term.php index 84f79460..52868dd6 100644 --- a/src/Term.php +++ b/src/Term.php @@ -9,20 +9,4 @@ class Term extends TimberTerm { use Macroable; - - /** - * Get the Term class associated with a taxonomy slug. - * - * @param string $taxonomy - * @return string|null - */ - public static function termClass(string $taxonomy): ?string - { - $classMap = apply_filters('timber/term/classmap', [ - 'post_tag' => Term::class, - 'category' => Term::class, - ]); - - return Arr::get($classMap, $taxonomy); - } } diff --git a/src/User.php b/src/User.php index 2d52619c..32d55acf 100644 --- a/src/User.php +++ b/src/User.php @@ -8,15 +8,4 @@ class User extends TimberUser { use Macroable; - - /** - * Get the User class associated with a user object. - * - * @param \WP_User|null $user - * @return string - */ - public static function userClass(?\WP_User $user = null): string - { - return apply_filters('timber/user/class', User::class, $user); - } } diff --git a/tests/Unit/Http/Resolvers/AbstractContextResolverTest.php b/tests/Unit/Http/Resolvers/AbstractContextResolverTest.php index 1cc3b3a6..1a57c307 100644 --- a/tests/Unit/Http/Resolvers/AbstractContextResolverTest.php +++ b/tests/Unit/Http/Resolvers/AbstractContextResolverTest.php @@ -2,14 +2,17 @@ namespace Rareloop\Lumberjack\Test\Unit\Http\Resolvers; +use stdClass; +use Exception; use Brain\Monkey\Functions; -use Mockery; use PHPUnit\Framework\Attributes\Test; use Rareloop\Lumberjack\Application; use Rareloop\Lumberjack\Http\Resolvers\AbstractContextResolver; use Rareloop\Lumberjack\Test\TestCase; use Rareloop\Lumberjack\Test\Unit\Concerns\BrainMonkeyPHPUnitIntegration; use Rareloop\Router\Invoker; +use Rareloop\Lumberjack\Exceptions\MissingContextException; +use Rareloop\Lumberjack\Exceptions\MismatchedContextException; class AbstractContextResolverTest extends TestCase { @@ -69,12 +72,12 @@ public function it_throws_an_exception_if_context_is_missing_and_not_nullable(): Functions\expect('get_queried_object')->once()->andReturn(null); $controller = new class { - public function handle(\stdClass $obj) + public function handle(stdClass $obj) { } }; - $this->expectException(\Rareloop\Lumberjack\Exceptions\MissingContextException::class); + $this->expectException(MissingContextException::class); $this->invoker->call([$controller, 'handle']); } @@ -84,7 +87,7 @@ 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) + public function handle(?stdClass $obj) { return $obj; } @@ -98,17 +101,17 @@ public function handle(?\stdClass $obj) #[Test] public function it_throws_an_exception_if_resolved_object_is_wrong_type(): void { - Functions\expect('get_queried_object')->once()->andReturn(new \stdClass()); + Functions\expect('get_queried_object')->once()->andReturn(new stdClass()); - $this->resolver->resolvedObject = new \Exception(); // Not a stdClass + $this->resolver->resolvedObject = new Exception(); // Not a stdClass $controller = new class { - public function handle(\stdClass $obj) + public function handle(stdClass $obj) { } }; - $this->expectException(\Rareloop\Lumberjack\Exceptions\MismatchedContextException::class); + $this->expectException(MismatchedContextException::class); $this->invoker->call([$controller, 'handle']); } } @@ -131,6 +134,6 @@ protected function isValidContext(mixed $context, string $className): bool protected function resolveObject(string $className, mixed $context): mixed { - return $this->resolvedObject ?? new \stdClass(); + return $this->resolvedObject ?? new stdClass(); } } diff --git a/tests/Unit/Http/Resolvers/NullableMultiParameterTest.php b/tests/Unit/Http/Resolvers/NullableMultiParameterTest.php index a31e48a6..ba62d9ad 100644 --- a/tests/Unit/Http/Resolvers/NullableMultiParameterTest.php +++ b/tests/Unit/Http/Resolvers/NullableMultiParameterTest.php @@ -9,7 +9,8 @@ use Rareloop\Lumberjack\Test\TestCase; use Rareloop\Lumberjack\Test\Unit\Concerns\BrainMonkeyPHPUnitIntegration; use Rareloop\Router\Invoker; -use Rareloop\Lumberjack\Exceptions\MismatchedContextException; +use WP_User; +use WP_Term; class NullableMultiParameterTest extends TestCase { @@ -22,11 +23,11 @@ protected function setUp(): void { parent::setUp(); - if (!class_exists('\WP_User')) { + if (!class_exists(WP_User::class)) { eval('class WP_User {}'); } - if (!class_exists('\WP_Term')) { + if (!class_exists(WP_Term::class)) { eval('class WP_Term {}'); } @@ -38,17 +39,17 @@ protected function setUp(): void 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()); + 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; + return $className === WP_User::class; } protected function isValidContext(mixed $context, string $className): bool { - return is_a($context, \WP_User::class); + return is_a($context, WP_User::class); } protected function resolveObject(string $className, mixed $context): mixed { @@ -59,11 +60,11 @@ protected function resolveObject(string $className, mixed $context): mixed $termResolver = new class extends AbstractContextResolver { protected function canResolveClass(string $className): bool { - return $className === \WP_Term::class; + return $className === WP_Term::class; } protected function isValidContext(mixed $context, string $className): bool { - return is_a($context, \WP_Term::class); + return is_a($context, WP_Term::class); } protected function resolveObject(string $className, mixed $context): mixed { @@ -76,7 +77,7 @@ protected function resolveObject(string $className, mixed $context): mixed $this->invoker->getParameterResolver()->prependResolver($termResolver); $controller = new class { - public function handle(?\WP_Term $term, ?\WP_User $user) + public function handle(?WP_Term $term, ?WP_User $user) { return ['term' => $term, 'user' => $user]; } @@ -86,6 +87,6 @@ public function handle(?\WP_Term $term, ?\WP_User $user) $result = $this->invoker->call([$controller, 'handle']); $this->assertNull($result['term']); - $this->assertInstanceOf(\WP_User::class, $result['user']); + $this->assertInstanceOf(WP_User::class, $result['user']); } } diff --git a/tests/Unit/Http/Resolvers/PostQueryResolverTest.php b/tests/Unit/Http/Resolvers/PostQueryResolverTest.php index 7cee4d15..76af4646 100644 --- a/tests/Unit/Http/Resolvers/PostQueryResolverTest.php +++ b/tests/Unit/Http/Resolvers/PostQueryResolverTest.php @@ -12,7 +12,9 @@ use Rareloop\Lumberjack\Test\Unit\Http\Resolvers\Stubs\PostQueryStub; use Rareloop\Router\Invoker; use Timber\PostQuery; +use Timber\PostCollectionInterface; use WP_Query; +use Rareloop\Lumberjack\Config; class PostQueryResolverTest extends TestCase { @@ -27,7 +29,7 @@ protected function setUp(): void $this->app = new Application(__DIR__ . '/../../../../'); - $config = Mockery::mock(\Rareloop\Lumberjack\Config::class); + $config = Mockery::mock(Config::class); $config->shouldReceive('get')->with('app.resolvers', [])->andReturn([]); $this->app->bind('config', $config); @@ -44,7 +46,7 @@ public function it_can_resolve_a_timber_post_query(): void $this->app->bind(WP_Query::class, $wpQuery); $controller = new class { - public function handle(\Timber\PostQuery $query) + public function handle(PostQuery $query) { return $query; } @@ -52,7 +54,7 @@ public function handle(\Timber\PostQuery $query) $result = $this->invoker->call([$controller, 'handle']); - $this->assertInstanceOf(\Timber\PostQuery::class, $result); + $this->assertInstanceOf(PostQuery::class, $result); } #[Test] @@ -62,7 +64,7 @@ public function it_can_resolve_a_post_collection_interface(): void $this->app->bind(WP_Query::class, $wpQuery); $controller = new class { - public function handle(\Timber\PostCollectionInterface $query) + public function handle(PostCollectionInterface $query) { return $query; } @@ -70,7 +72,7 @@ public function handle(\Timber\PostCollectionInterface $query) $result = $this->invoker->call([$controller, 'handle']); - $this->assertInstanceOf(\Timber\PostCollectionInterface::class, $result); + $this->assertInstanceOf(PostCollectionInterface::class, $result); } #[Test] diff --git a/tests/Unit/Http/Resolvers/PostResolverTest.php b/tests/Unit/Http/Resolvers/PostResolverTest.php index d2700109..544df789 100644 --- a/tests/Unit/Http/Resolvers/PostResolverTest.php +++ b/tests/Unit/Http/Resolvers/PostResolverTest.php @@ -14,6 +14,7 @@ use Timber\Post; use Timber\Timber; use WP_Post; +use Rareloop\Lumberjack\Config; use PHPUnit\Framework\Attributes\PreserveGlobalState; use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses; @@ -32,7 +33,7 @@ protected function setUp(): void $this->app = new Application(__DIR__ . '/../../../../'); - $config = Mockery::mock(\Rareloop\Lumberjack\Config::class); + $config = Mockery::mock(Config::class); $config->shouldReceive('get')->with('app.resolvers', [])->andReturn([]); $this->app->bind('config', $config); diff --git a/tests/Unit/Http/Resolvers/PostTypeResolverTest.php b/tests/Unit/Http/Resolvers/PostTypeResolverTest.php index 417c65bd..d1865db0 100644 --- a/tests/Unit/Http/Resolvers/PostTypeResolverTest.php +++ b/tests/Unit/Http/Resolvers/PostTypeResolverTest.php @@ -12,6 +12,9 @@ use Rareloop\Lumberjack\Test\Unit\Concerns\BrainMonkeyPHPUnitIntegration; use Rareloop\Router\Invoker; use Timber\PostType as TimberPostType; +use WP_Post; +use WP_Post_Type; +use Rareloop\Lumberjack\Config; class PostTypeResolverTest extends TestCase { @@ -26,7 +29,7 @@ protected function setUp(): void $this->app = new Application(__DIR__ . '/../../../../'); - $config = Mockery::mock(\Rareloop\Lumberjack\Config::class); + $config = Mockery::mock(Config::class); $config->shouldReceive('get')->with('app.resolvers', [])->andReturn([]); $this->app->bind('config', $config); @@ -39,7 +42,7 @@ protected function setUp(): void #[Test] public function it_can_resolve_a_post_type_from_a_post_type_object(): void { - $wpPostType = Mockery::mock('WP_Post_Type'); + $wpPostType = Mockery::mock(WP_Post_Type::class); $wpPostType->name = 'page'; Functions\expect('get_queried_object')->once()->andReturn($wpPostType); @@ -62,11 +65,11 @@ public function handle(LumberjackPostType $postType) #[Test] public function it_can_resolve_a_post_type_from_a_post_object(): void { - $wpPost = Mockery::mock('WP_Post'); + $wpPost = Mockery::mock(WP_Post::class); $wpPost->post_type = 'post'; Functions\expect('get_queried_object')->once()->andReturn($wpPost); - $wpPostTypeObject = Mockery::mock('WP_Post_Type'); + $wpPostTypeObject = Mockery::mock(WP_Post_Type::class); $wpPostTypeObject->name = 'post'; // Called once by our resolver and once by the Timber\PostType constructor @@ -88,7 +91,7 @@ public function handle(LumberjackPostType $postType) #[Test] public function it_can_resolve_a_timber_post_type(): void { - $wpPostType = Mockery::mock('WP_Post_Type'); + $wpPostType = Mockery::mock(WP_Post_Type::class); $wpPostType->name = 'page'; Functions\expect('get_queried_object')->once()->andReturn($wpPostType); diff --git a/tests/Unit/Http/Resolvers/TermResolverTest.php b/tests/Unit/Http/Resolvers/TermResolverTest.php index 7c2961cb..95ecb733 100644 --- a/tests/Unit/Http/Resolvers/TermResolverTest.php +++ b/tests/Unit/Http/Resolvers/TermResolverTest.php @@ -14,6 +14,7 @@ use Timber\Term; use Timber\Timber; use WP_Term; +use Rareloop\Lumberjack\Config; use PHPUnit\Framework\Attributes\PreserveGlobalState; use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses; @@ -32,7 +33,7 @@ protected function setUp(): void $this->app = new Application(__DIR__ . '/../../../../'); - $config = Mockery::mock(\Rareloop\Lumberjack\Config::class); + $config = Mockery::mock(Config::class); $config->shouldReceive('get')->with('app.resolvers', [])->andReturn([]); $this->app->bind('config', $config); diff --git a/tests/Unit/Http/Resolvers/UserResolverTest.php b/tests/Unit/Http/Resolvers/UserResolverTest.php index 68058005..11414fc7 100644 --- a/tests/Unit/Http/Resolvers/UserResolverTest.php +++ b/tests/Unit/Http/Resolvers/UserResolverTest.php @@ -14,6 +14,7 @@ use Timber\User; use Timber\Timber; use WP_User; +use Rareloop\Lumberjack\Config; use PHPUnit\Framework\Attributes\PreserveGlobalState; use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses; @@ -32,7 +33,7 @@ protected function setUp(): void $this->app = new Application(__DIR__ . '/../../../../'); - $config = Mockery::mock(\Rareloop\Lumberjack\Config::class); + $config = Mockery::mock(Config::class); $config->shouldReceive('get')->with('app.resolvers', [])->andReturn([]); $this->app->bind('config', $config); diff --git a/tests/Unit/Http/Responses/TimberResponseTest.php b/tests/Unit/Http/Responses/TimberResponseTest.php index e9ab2f4c..fc278046 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,9 @@ 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] @@ -224,6 +236,9 @@ public function contexts_that_are_arrayable_objects_are_converted(): void ->andReturn('testing123'); $response = new TimberResponse('template.twig', $context, 123); + + $this->assertSame(123, $response->getStatusCode()); + $this->assertSame('testing123', $response->getBody()->__toString()); } #[Test] @@ -249,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/PostTest.php b/tests/Unit/PostTest.php index a142ef19..083db253 100644 --- a/tests/Unit/PostTest.php +++ b/tests/Unit/PostTest.php @@ -11,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; @@ -41,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(); } @@ -153,11 +155,11 @@ public function can_get_post_class_from_classmap(): void ->times(3) ->andReturn([ 'post' => Post::class, - 'page' => \Rareloop\Lumberjack\Page::class, + 'page' => Page::class, ]); $this->assertSame(Post::class, Post::postClass('post')); - $this->assertSame(\Rareloop\Lumberjack\Page::class, Post::postClass('page')); + $this->assertSame(Page::class, Post::postClass('page')); $this->assertNull(Post::postClass('missing')); } diff --git a/tests/Unit/PostTypeTest.php b/tests/Unit/PostTypeTest.php index d2e2d2fc..6b78b863 100644 --- a/tests/Unit/PostTypeTest.php +++ b/tests/Unit/PostTypeTest.php @@ -10,6 +10,8 @@ use Rareloop\Lumberjack\Test\TestCase; use Rareloop\Lumberjack\Test\Unit\Concerns\BrainMonkeyPHPUnitIntegration; use Mockery; +use WP_Post_Type; +use stdClass; class PostTypeTest extends TestCase { @@ -18,7 +20,7 @@ class PostTypeTest extends TestCase #[Test] public function it_extends_the_timber_post_type_class(): void { - Functions\expect('get_post_type_object')->andReturn(Mockery::mock('WP_Post_Type')); + Functions\expect('get_post_type_object')->andReturn(Mockery::mock(WP_Post_Type::class)); $postType = new PostType('post'); $this->assertInstanceOf(TimberPostType::class, $postType); @@ -27,7 +29,7 @@ public function it_extends_the_timber_post_type_class(): void #[Test] public function can_get_post_class_associated_with_post_type(): void { - $wpPostType = new \stdClass(); + $wpPostType = new stdClass(); $wpPostType->name = 'book'; Functions\expect('get_post_type_object')->andReturn($wpPostType); @@ -43,7 +45,7 @@ public function can_get_post_class_associated_with_post_type(): void #[Test] public function can_extend_post_type_with_macros(): void { - $wpPostType = new \stdClass(); + $wpPostType = new stdClass(); $wpPostType->name = 'post'; Functions\expect('get_post_type_object')->andReturn($wpPostType); diff --git a/tests/Unit/TermTest.php b/tests/Unit/TermTest.php index 06f66508..dc2c5fee 100644 --- a/tests/Unit/TermTest.php +++ b/tests/Unit/TermTest.php @@ -4,7 +4,6 @@ use PHPUnit\Framework\Attributes\Test; use Rareloop\Lumberjack\Term; -use Timber\Term as TimberTerm; use Rareloop\Lumberjack\Test\TestCase; use Rareloop\Lumberjack\Test\Unit\Concerns\BrainMonkeyPHPUnitIntegration; @@ -12,15 +11,6 @@ class TermTest extends TestCase { use BrainMonkeyPHPUnitIntegration; - #[Test] - public function it_extends_the_timber_term_class(): void - { - $term = TestableTerm::create(); - - $this->assertInstanceOf(TimberTerm::class, $term); - $this->assertInstanceOf(Term::class, $term); - } - #[Test] public function can_extend_term_with_macros(): void { @@ -28,34 +18,13 @@ public function can_extend_term_with_macros(): void return 'macro_result'; }); - $term = TestableTerm::create(); + $termClass = new class extends Term + { + public function __construct() + { + } + }; - $this->assertSame('macro_result', $term->testMacro()); - } - - #[Test] - public function can_get_term_class_from_classmap(): void - { - \Brain\Monkey\Filters\expectApplied('timber/term/classmap') - ->times(3) - ->andReturn([ - 'category' => Term::class, - 'book_cat' => 'App\Term\BookCategory', - ]); - - $this->assertSame(Term::class, Term::termClass('category')); - $this->assertSame('App\Term\BookCategory', Term::termClass('book_cat')); - $this->assertNull(Term::termClass('missing')); - } -} - -class TestableTerm extends Term -{ - public function __construct() - { - } - public static function create() - { - return new static(); + $this->assertSame('macro_result', (new $termClass())->testMacro()); } } diff --git a/tests/Unit/UserTest.php b/tests/Unit/UserTest.php index 1ed7a5e9..3974fbac 100644 --- a/tests/Unit/UserTest.php +++ b/tests/Unit/UserTest.php @@ -4,24 +4,13 @@ use PHPUnit\Framework\Attributes\Test; use Rareloop\Lumberjack\User; -use Timber\User as TimberUser; use Rareloop\Lumberjack\Test\TestCase; use Rareloop\Lumberjack\Test\Unit\Concerns\BrainMonkeyPHPUnitIntegration; -use Mockery; class UserTest extends TestCase { use BrainMonkeyPHPUnitIntegration; - #[Test] - public function it_extends_the_timber_user_class(): void - { - $user = TestableUser::create(); - - $this->assertInstanceOf(TimberUser::class, $user); - $this->assertInstanceOf(User::class, $user); - } - #[Test] public function can_extend_user_with_macros(): void { @@ -29,32 +18,13 @@ public function can_extend_user_with_macros(): void return 'macro_result'; }); - $user = TestableUser::create(); - - $this->assertSame('macro_result', $user->testMacro()); - } - - #[Test] - public function can_get_user_class_from_filter(): void - { - $wpUser = Mockery::mock(\WP_User::class); - - \Brain\Monkey\Filters\expectApplied('timber/user/class') - ->once() - ->with(User::class, $wpUser) - ->andReturn('App\User\Administrator'); - - $this->assertSame('App\User\Administrator', User::userClass($wpUser)); - } -} + $userClass = new class extends User + { + public function __construct() + { + } + }; -class TestableUser extends User -{ - public function __construct() - { - } - public static function create() - { - return new static(); + $this->assertSame('macro_result', (new $userClass())->testMacro()); } } From e1bf2ef25b969496c8da9b5b67b872d6983b6ccc Mon Sep 17 00:00:00 2001 From: Tom Mitchelmore Date: Wed, 20 May 2026 12:11:51 +0100 Subject: [PATCH 26/27] Remove PostTypeResolver --- src/Http/Resolvers/PostTypeResolver.php | 38 ----- src/Post.php | 11 -- src/PostType.php | 21 --- .../WordPressControllersServiceProvider.php | 3 - .../Http/Resolvers/PostTypeResolverTest.php | 160 ------------------ tests/Unit/PostTest.php | 15 -- tests/Unit/PostTypeTest.php | 60 ------- 7 files changed, 308 deletions(-) delete mode 100644 src/Http/Resolvers/PostTypeResolver.php delete mode 100644 src/PostType.php delete mode 100644 tests/Unit/Http/Resolvers/PostTypeResolverTest.php delete mode 100644 tests/Unit/PostTypeTest.php diff --git a/src/Http/Resolvers/PostTypeResolver.php b/src/Http/Resolvers/PostTypeResolver.php deleted file mode 100644 index e79fb070..00000000 --- a/src/Http/Resolvers/PostTypeResolver.php +++ /dev/null @@ -1,38 +0,0 @@ -name); - } - - if (is_a($context, WP_Post::class) && function_exists('get_post_type_object')) { - $postTypeObject = get_post_type_object($context->post_type); - - if ($postTypeObject) { - return new PostType($postTypeObject->name); - } - } - - return null; - } -} diff --git a/src/Post.php b/src/Post.php index 28d57c8b..76728670 100644 --- a/src/Post.php +++ b/src/Post.php @@ -113,17 +113,6 @@ public static function filterTimberPostClassMap(array $classMap): array return [...$classMap, static::getPostType() => static::class]; } - /** - * Get the Post class associated with a post type slug. - * - * @param string $postType - * @return string|null - */ - public static function postClass(string $postType): ?string - { - return Arr::get(apply_filters('timber/post/classmap', []), $postType); - } - /** * Get all posts of this type * diff --git a/src/PostType.php b/src/PostType.php deleted file mode 100644 index 22f2ce33..00000000 --- a/src/PostType.php +++ /dev/null @@ -1,21 +0,0 @@ -name); - } -} diff --git a/src/Providers/WordPressControllersServiceProvider.php b/src/Providers/WordPressControllersServiceProvider.php index 817a4088..64737373 100644 --- a/src/Providers/WordPressControllersServiceProvider.php +++ b/src/Providers/WordPressControllersServiceProvider.php @@ -11,10 +11,8 @@ use Rareloop\Lumberjack\Http\Middleware\PasswordProtected; use Laminas\Diactoros\ServerRequestFactory; use Rareloop\Router\ProvidesControllerMiddleware; -use Invoker\ParameterResolver\ResolverChain; use Rareloop\Lumberjack\Http\Resolvers\PostQueryResolver; use Rareloop\Lumberjack\Http\Resolvers\UserResolver; -use Rareloop\Lumberjack\Http\Resolvers\PostTypeResolver; use Rareloop\Lumberjack\Http\Resolvers\PostResolver; use Rareloop\Lumberjack\Http\Resolvers\TermResolver; @@ -137,7 +135,6 @@ private function createDispatcher(array $middlewares): Dispatcher private function getCoreResolvers(): array { return [ - PostTypeResolver::class, PostQueryResolver::class, PostResolver::class, TermResolver::class, diff --git a/tests/Unit/Http/Resolvers/PostTypeResolverTest.php b/tests/Unit/Http/Resolvers/PostTypeResolverTest.php deleted file mode 100644 index d1865db0..00000000 --- a/tests/Unit/Http/Resolvers/PostTypeResolverTest.php +++ /dev/null @@ -1,160 +0,0 @@ -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_post_type_from_a_post_type_object(): void - { - $wpPostType = Mockery::mock(WP_Post_Type::class); - $wpPostType->name = 'page'; - Functions\expect('get_queried_object')->once()->andReturn($wpPostType); - - // Timber\PostType constructor calls get_post_type_object - Functions\expect('get_post_type_object')->once()->with('page')->andReturn($wpPostType); - - $controller = new class { - public function handle(LumberjackPostType $postType) - { - return $postType; - } - }; - - $result = $this->invoker->call([$controller, 'handle']); - - $this->assertInstanceOf(LumberjackPostType::class, $result); - $this->assertSame('page', (string)$result); - } - - #[Test] - public function it_can_resolve_a_post_type_from_a_post_object(): void - { - $wpPost = Mockery::mock(WP_Post::class); - $wpPost->post_type = 'post'; - Functions\expect('get_queried_object')->once()->andReturn($wpPost); - - $wpPostTypeObject = Mockery::mock(WP_Post_Type::class); - $wpPostTypeObject->name = 'post'; - - // Called once by our resolver and once by the Timber\PostType constructor - Functions\expect('get_post_type_object')->twice()->with('post')->andReturn($wpPostTypeObject); - - $controller = new class { - public function handle(LumberjackPostType $postType) - { - return $postType; - } - }; - - $result = $this->invoker->call([$controller, 'handle']); - - $this->assertInstanceOf(LumberjackPostType::class, $result); - $this->assertSame('post', (string)$result); - } - - #[Test] - public function it_can_resolve_a_timber_post_type(): void - { - $wpPostType = Mockery::mock(WP_Post_Type::class); - $wpPostType->name = 'page'; - Functions\expect('get_queried_object')->once()->andReturn($wpPostType); - - Functions\expect('get_post_type_object')->once()->with('page')->andReturn($wpPostType); - - $controller = new class { - public function handle(TimberPostType $postType) - { - return $postType; - } - }; - - $result = $this->invoker->call([$controller, 'handle']); - - $this->assertInstanceOf(TimberPostType::class, $result); - $this->assertSame('page', (string)$result); - } - - #[Test] - public function it_throws_an_exception_if_the_context_is_missing(): void - { - Functions\expect('get_queried_object')->once()->andReturn(null); - - $controller = new class { - public function handle(LumberjackPostType $postType) - { - } - }; - - $this->expectException(\Rareloop\Lumberjack\Exceptions\MissingContextException::class); - - $this->invoker->call([$controller, 'handle']); - } - - #[Test] - public function it_resolves_to_null_if_the_typehint_is_nullable_and_context_is_missing(): void - { - Functions\expect('get_queried_object')->once()->andReturn(null); - - $controller = new class { - public function handle(?LumberjackPostType $postType) - { - return $postType; - } - }; - - $result = $this->invoker->call([$controller, 'handle']); - - $this->assertNull($result); - } - - #[Test] - public function it_ignores_builtin_typehints(): void - { - $controller = new class { - public function handle(int $id) - { - return $id; - } - }; - - $result = $this->invoker->call([$controller, 'handle'], ['id' => 123]); - - $this->assertSame(123, $result); - } -} diff --git a/tests/Unit/PostTest.php b/tests/Unit/PostTest.php index 083db253..1c42fa7c 100644 --- a/tests/Unit/PostTest.php +++ b/tests/Unit/PostTest.php @@ -148,21 +148,6 @@ public function all_defaults_to_unlimited_ordered_by_menu_order_ascending() $this->assertInstanceOf(Collection::class, $posts); } - #[Test] - public function can_get_post_class_from_classmap(): void - { - Filters\expectApplied('timber/post/classmap') - ->times(3) - ->andReturn([ - 'post' => Post::class, - 'page' => Page::class, - ]); - - $this->assertSame(Post::class, Post::postClass('post')); - $this->assertSame(Page::class, Post::postClass('page')); - $this->assertNull(Post::postClass('missing')); - } - #[Test] public function all_can_have_post_limit_set() { diff --git a/tests/Unit/PostTypeTest.php b/tests/Unit/PostTypeTest.php deleted file mode 100644 index 6b78b863..00000000 --- a/tests/Unit/PostTypeTest.php +++ /dev/null @@ -1,60 +0,0 @@ -andReturn(Mockery::mock(WP_Post_Type::class)); - $postType = new PostType('post'); - - $this->assertInstanceOf(TimberPostType::class, $postType); - } - - #[Test] - public function can_get_post_class_associated_with_post_type(): void - { - $wpPostType = new stdClass(); - $wpPostType->name = 'book'; - Functions\expect('get_post_type_object')->andReturn($wpPostType); - - $postType = new PostType('book'); - - Filters\expectApplied('timber/post/classmap') - ->once() - ->andReturn(['book' => 'App\Post\Book']); - - $this->assertSame('App\Post\Book', $postType->postClass()); - } - - #[Test] - public function can_extend_post_type_with_macros(): void - { - $wpPostType = new stdClass(); - $wpPostType->name = 'post'; - Functions\expect('get_post_type_object')->andReturn($wpPostType); - - PostType::macro('testMacro', function () { - return 'macro_result'; - }); - - $postType = new PostType('post'); - - $this->assertSame('macro_result', $postType->testMacro()); - } -} From b234850237da4679e8edb6afdf84445c310124ea Mon Sep 17 00:00:00 2001 From: Tom Mitchelmore Date: Wed, 20 May 2026 15:18:16 +0100 Subject: [PATCH 27/27] Remove unused import --- src/Post.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Post.php b/src/Post.php index 76728670..376699bc 100644 --- a/src/Post.php +++ b/src/Post.php @@ -7,7 +7,6 @@ use Spatie\Macroable\Macroable; use Timber\Post as TimberPost; use Timber\Timber; -use Illuminate\Support\Arr; class Post extends TimberPost {