Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 121 additions & 0 deletions tests/Integration/Controller/CoreControllerDispatchTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
<?php
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2026 WebTigers. Tiger™ and WebTigers™ are trademarks of WebTigers.

namespace Tiger\Tests\Integration\Controller;

use ApiController;
use AuthController;
use IndexController;
use PHPUnit\Framework\Attributes\Test;
use Tiger\Tests\Support\ControllerTestCase;
use Zend_Session;

/**
* Proves the ControllerTestCase dispatch harness end-to-end against the real default-namespace
* controllers (`core/controllers/*`): a controller is instantiated + dispatched with view-rendering
* off, and the action's OUTCOME — a `_json`/`echo` body, an ACL denial, a redirect, a `_forward` —
* is asserted. This is the gate that unblocks covering `core/controllers` (0% before) and the module
* controllers in the service waves.
*/
final class CoreControllerDispatchTest extends ControllerTestCase
{
private bool $priorUnitTestMode;

protected function setUp(): void
{
parent::setUp();
$this->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 '';
}
}
163 changes: 163 additions & 0 deletions tests/Support/ControllerTestCase.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
<?php
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2026 WebTigers. Tiger™ and WebTigers™ are trademarks of WebTigers.

namespace Tiger\Tests\Support;

use Zend_Controller_Action_HelperBroker;
use Zend_Controller_Front;
use Zend_Controller_Request_Http;
use Zend_Controller_Response_Http;
use Zend_Layout;
use Zend_View;

/**
* ControllerTestCase — a minimal ZF1 dispatch harness for covering the default-namespace controllers
* (`core/controllers/*`) and any module controller, WITHOUT booting the whole `Tiger_Application`.
*
* The harness dispatches a controller ACTION with view-rendering turned OFF and returns the response +
* the controller instance for inspection. Turning off the ViewRenderer is deliberate: the goal is
* controller-ACTION coverage — the branch logic, the view-var assignment, the `_json` body, the
* redirect / `_forward` decision — not the `.phtml` output (view scripts aren't PHP classes and aren't
* in the coverage source set). So we skip the theme/layout/view-helper stack a full render would need,
* and assert on what the action DID: `$response->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(),
] : [];
}
}
8 changes: 8 additions & 0 deletions tests/bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand Down
Loading