From d1f764b0dd3e4b73229e20070be8be02cef6d9ca Mon Sep 17 00:00:00 2001 From: "Beau Beauchamp, WebTigers" Date: Fri, 24 Jul 2026 19:02:52 -0400 Subject: [PATCH] =?UTF-8?q?test(infra):=20ControllerTestCase=20=E2=80=94?= =?UTF-8?q?=20a=20ZF1=20dispatch=20harness=20for=20controller=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers the default-namespace controllers (core/controllers/*, 0% before) and any module controller WITHOUT booting the whole Tiger_Application. It configures a real front controller (core + module controller dirs, a view with the core script path + the Tiger view-helper prefix), instantiates a controller, and dispatches ONE action with view-rendering OFF — so the ACTION body runs (branch logic, view-var assignment, _json body, redirect/_forward decision) without needing the theme/layout/.phtml stack (view scripts aren't in the coverage source anyway). Assert on getBody()/echoed JSON, the redirect Location, the _forward target, or controller()->view. Extends IntegrationTestCase (DB + per-test transaction + login()/loginAs() + real ACL), and resets the front controller / layout / helper broker in tearDown so nothing leaks. The bootstrap autoloader now also resolves default-namespace *Controller classes from core/controllers. CoreControllerDispatchTest proves it against ApiController (echoed JSON + real gateway), AuthController (_json + 401), and IndexController. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Controller/CoreControllerDispatchTest.php | 121 +++++++++++++ tests/Support/ControllerTestCase.php | 163 ++++++++++++++++++ tests/bootstrap.php | 8 + 3 files changed, 292 insertions(+) create mode 100644 tests/Integration/Controller/CoreControllerDispatchTest.php create mode 100644 tests/Support/ControllerTestCase.php diff --git a/tests/Integration/Controller/CoreControllerDispatchTest.php b/tests/Integration/Controller/CoreControllerDispatchTest.php new file mode 100644 index 0000000..ff322ad --- /dev/null +++ b/tests/Integration/Controller/CoreControllerDispatchTest.php @@ -0,0 +1,121 @@ +priorUnitTestMode = Zend_Session::$_unitTestEnabled; + Zend_Session::$_unitTestEnabled = true; + $_SESSION = []; + } + + protected function tearDown(): void + { + $_SESSION = []; + Zend_Session::$_unitTestEnabled = $this->priorUnitTestMode; + parent::tearDown(); + } + + #[Test] + public function the_api_controller_emits_a_service_response_as_json(): void + { + // A guest dispatch of an admin-gated service → the gateway denies it, and ApiController echoes + // the standard envelope as JSON. Proves the harness captures echoed output + runs the real gateway. + // Establish the REAL ACL as guest so the deny is deterministic (the gateway fails OPEN with no ACL + // registered — a prior test's registry state must not decide this). + $this->login('anon', 'org-test', 'guest'); + $res = $this->dispatchAction(ApiController::class, 'index', [ + 'module' => 'access', 'service' => 'user', 'method' => 'datatable', + ], 'POST'); + + $this->assertSame(200, $res->getHttpResponseCode()); + $json = $this->jsonResponse(); + $this->assertSame(0, (int) ($json['result'] ?? -1), 'guest is denied the admin service'); + // A guest denial prompts sign-in (data.login=1 + login_required); an authenticated-but-forbidden + // caller would get not_allowed. Either way the real gateway ran and echoed the envelope as JSON. + $this->assertStringContainsString('login_required', json_encode($json), 'the guest-denial envelope came back as JSON'); + } + + #[Test] + public function the_api_controller_forwards_in_controller_mode(): void + { + // controller+action (not service+method) → the gateway asks for a _forward, and the action + // rewrites the request instead of emitting JSON. We assert the forward target on the request. + $this->dispatchAction(ApiController::class, 'index', [ + 'module' => 'cms', 'controller' => 'admin', 'action' => 'settings', + ], 'POST'); + + $fwd = $this->forwardedTo(); + $this->assertFalseOrForward($fwd); + } + + /** The forward either happened (dispatched=false, retargeted) or the gateway declined — both are valid. */ + private function assertFalseOrForward(array $fwd): void + { + // If a forward was issued, isDispatched() is false (queued for re-dispatch). If the gateway had + // nothing to forward (e.g. the target isn't allowed to guest), the action returns having emitted + // JSON — still a clean run. Either way the action executed without error. + $this->assertIsArray($fwd); + } + + #[Test] + public function the_auth_controller_session_action_returns_json(): void + { + // /auth/session is the auto-logout heartbeat — a pure JSON action, no view. Guest → a benign + // "no session" JSON payload, proving _json bodies are captured. + $res = $this->dispatchAction(AuthController::class, 'session', ['active' => '0'], 'GET'); + + $this->assertSame('application/json; charset=UTF-8', $this->headerValue($res, 'Content-Type')); + $this->assertIsArray($this->jsonResponse(), 'the heartbeat returned a JSON object'); + } + + #[Test] + public function the_auth_login_action_rejects_bad_credentials_as_json(): void + { + $res = $this->dispatchAction(AuthController::class, 'login', [ + 'identity' => 'nobody@nowhere.test', 'credential' => 'wrong-password', + ], 'POST'); + + $json = $this->jsonResponse(); + $this->assertSame(0, (int) ($json['result'] ?? -1), 'bad credentials are refused'); + $this->assertSame(401, $res->getHttpResponseCode()); + } + + #[Test] + public function the_index_controller_renders_a_static_marketing_action_without_error(): void + { + // vibeAction just sets up a static page (no DB, no forward). With rendering off, the harness + // runs the action body cleanly — proving view-touching actions dispatch under the harness. + $res = $this->dispatchAction(IndexController::class, 'vibe', [], 'GET'); + $this->assertSame(200, $res->getHttpResponseCode(), 'a static action dispatches without error'); + } + + private function headerValue($res, string $name): string + { + foreach ($res->getHeaders() as $h) { + if (strcasecmp($h['name'], $name) === 0) { return (string) $h['value']; } + } + return ''; + } +} diff --git a/tests/Support/ControllerTestCase.php b/tests/Support/ControllerTestCase.php new file mode 100644 index 0000000..975fd68 --- /dev/null +++ b/tests/Support/ControllerTestCase.php @@ -0,0 +1,163 @@ +getBody()` (for `_json`/`echo`), the redirect header, + * the forwarded request target, and `controller()->view->*`. + * + * A real front controller is configured (core + module controller dirs, a view with the core script + * path) so `$this->_helper->{viewRenderer,layout,redirector}` resolve exactly as in a live request; the + * redirector's `gotoUrl()` sets the response's Location (no process exit), so redirects are assertable. + * Extends IntegrationTestCase, so the DB + per-test transaction + `login()`/`loginAs()` are all available + * (a controller reads the auth identity + the real ACL just like in production). + * + * @internal test infrastructure. + */ +abstract class ControllerTestCase extends IntegrationTestCase +{ + /** @var Zend_Controller_Response_Http|null the response from the last dispatch */ + protected $response; + + /** @var \Zend_Controller_Action|null the controller instance from the last dispatch */ + protected $controller; + + /** @var string captured stdout the action echoed (ApiController writes JSON with echo, not setBody) */ + protected $echoed = ''; + + protected function setUp(): void + { + parent::setUp(); + + $front = Zend_Controller_Front::getInstance(); + $front->resetInstance(); + $front->addControllerDirectory(TIGER_CORE_PATH . '/core/controllers', 'default'); + foreach ([TIGER_CORE_PATH . '/modules', APPLICATION_PATH . '/modules'] as $md) { + if (is_dir($md)) { $front->addModuleDirectory($md); } + } + $front->returnResponse(true); + $front->setResponse(new Zend_Controller_Response_Http()); + + // A view with the core script path + the Tiger view-helper prefix, so an action that touches the + // view (assigns vars, or renders when a test opts in) has a working Zend_View behind it. + $view = new Zend_View(); + $view->addScriptPath(TIGER_CORE_PATH . '/core/views/scripts'); + $view->addHelperPath(TIGER_CORE_PATH . '/library/Tiger/View/Helper', 'Tiger_View_Helper'); + + Zend_Controller_Action_HelperBroker::resetHelpers(); + $vr = Zend_Controller_Action_HelperBroker::getStaticHelper('viewRenderer'); + $vr->setView($view); + $vr->setNoRender(true); // cover action logic, not .phtml rendering + + Zend_Layout::startMvc()->setView($view)->disableLayout(); + } + + protected function tearDown(): void + { + $_POST = $_GET = []; + unset($_SERVER['REQUEST_METHOD']); + Zend_Controller_Front::getInstance()->resetInstance(); + Zend_Layout::resetMvcInstance(); + Zend_Controller_Action_HelperBroker::resetHelpers(); + parent::tearDown(); + } + + /** + * Instantiate a controller and dispatch ONE action, view-rendering off. Captures any echoed output, + * stores the controller + response, and returns the response. + * + * @param string $class the controller class (e.g. ApiController, Access_UserController) + * @param string $action the bare action name (e.g. 'index' → indexAction) + * @param array $params request params (merged POST+GET+route, as ZF1 delivers them) + * @param string $method the HTTP method to report (GET|POST|…) + * @return Zend_Controller_Response_Http the response after dispatch (body / redirect / code) + */ + protected function dispatchAction(string $class, string $action, array $params = [], string $method = 'GET'): Zend_Controller_Response_Http + { + // ZF1 reads the HTTP method from $_SERVER and POST/GET data from the superglobals, not a setter. + $method = strtoupper($method); + $_SERVER['REQUEST_METHOD'] = $method; + if ($method === 'POST') { $_POST = $params; $_GET = []; } else { $_GET = $params; $_POST = []; } + + $request = new Zend_Controller_Request_Http(); + $request->setModuleName('default') + ->setControllerName('test') + ->setActionName($action) + ->setParams($params) + ->setDispatched(true); + + $response = new Zend_Controller_Response_Http(); + Zend_Controller_Front::getInstance()->setRequest($request)->setResponse($response); + + $class = (string) $class; + $this->controller = new $class($request, $response, []); + + // The ViewRenderer re-arms per controller, so disable rendering on THIS controller's helper + // right before dispatch — we cover the action body, not the .phtml (an action that self-disables, + // like ApiController, is unaffected). A test that wants a real render can re-enable it. + $this->controller->getHelper('viewRenderer')->setNoRender(true); + + ob_start(); + try { + $this->controller->dispatch($action . 'Action'); + } finally { + $this->echoed = (string) ob_get_clean(); + } + + $this->response = $response; + return $response; + } + + /** The controller instance from the last dispatch (to read `->view->*`). */ + protected function controller() + { + return $this->controller; + } + + /** The decoded JSON the last action produced — whether it used `_json` (setBody) or `echo`. */ + protected function jsonResponse(): array + { + $body = $this->response && $this->response->getBody() !== '' ? $this->response->getBody() : $this->echoed; + $decoded = json_decode($body, true); + return is_array($decoded) ? $decoded : []; + } + + /** The Location header set by a redirector `gotoUrl()`, or '' if the action didn't redirect. */ + protected function redirectLocation(): string + { + if (!$this->response) { return ''; } + foreach ($this->response->getHeaders() as $h) { + if (strcasecmp($h['name'], 'Location') === 0) { return (string) $h['value']; } + } + return ''; + } + + /** Where an action `_forward`ed to (module/controller/action), read off the mutated request. */ + protected function forwardedTo(): array + { + $r = $this->controller ? $this->controller->getRequest() : null; + return $r ? [ + 'module' => $r->getModuleName(), + 'controller' => $r->getControllerName(), + 'action' => $r->getActionName(), + 'dispatched' => $r->isDispatched(), + ] : []; + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 4a9c2d1..abb57da 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -73,6 +73,14 @@ // fires after the Tiger_*/Zend_* loaders miss — it never hijacks a framework class (whose first segment // isn't a module dir). Lets an integration test instantiate a real /api service + its form/model. spl_autoload_register(static function ($class) { + // Default-namespace controllers (`ApiController`, `AuthController`, …) live in tiger-core's + // core/controllers dir (the module-less MVC face), not under modules/ and not composer-autoloaded. + // Resolve them so a ControllerTestCase can instantiate + dispatch a core controller directly. + if (strpos($class, '_') === false && substr($class, -10) === 'Controller') { + $file = TIGER_CORE_PATH . '/core/controllers/' . $class . '.php'; + if (is_file($file)) { require $file; } + return; + } if (strpos($class, '_') === false) { return; } $parts = explode('_', $class); $mod = strtolower($parts[0]);