This commit is contained in:
Markus
2022-04-28 09:40:10 +02:00
commit 795794f992
9586 changed files with 1146991 additions and 0 deletions

View File

@@ -0,0 +1,24 @@
<?php
/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace CodeIgniter\Test;
/**
* CIDatabaseTestCase
*
* Use DatabaseTestTrait instead.
*
* @deprecated 4.1.2
*/
abstract class CIDatabaseTestCase extends CIUnitTestCase
{
use DatabaseTestTrait;
}

View File

@@ -0,0 +1,541 @@
<?php
/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace CodeIgniter\Test;
use CodeIgniter\CodeIgniter;
use CodeIgniter\Config\Factories;
use CodeIgniter\Database\BaseConnection;
use CodeIgniter\Database\MigrationRunner;
use CodeIgniter\Database\Seeder;
use CodeIgniter\Events\Events;
use CodeIgniter\Router\RouteCollection;
use CodeIgniter\Session\Handlers\ArrayHandler;
use CodeIgniter\Test\Mock\MockCache;
use CodeIgniter\Test\Mock\MockCodeIgniter;
use CodeIgniter\Test\Mock\MockEmail;
use CodeIgniter\Test\Mock\MockSession;
use Config\App;
use Config\Autoload;
use Config\Modules;
use Config\Services;
use Exception;
use PHPUnit\Framework\TestCase;
/**
* Framework test case for PHPUnit.
*/
abstract class CIUnitTestCase extends TestCase
{
use ReflectionHelper;
/**
* @var CodeIgniter
*/
protected $app;
/**
* Methods to run during setUp.
*
* @var array of methods
*/
protected $setUpMethods = [
'resetFactories',
'mockCache',
'mockEmail',
'mockSession',
];
/**
* Methods to run during tearDown.
*
* @var array of methods
*/
protected $tearDownMethods = [];
/**
* Store of identified traits.
*
* @var string[]|null
*/
private $traits;
//--------------------------------------------------------------------
// Database Properties
//--------------------------------------------------------------------
/**
* Should run db migration?
*
* @var bool
*/
protected $migrate = true;
/**
* Should run db migration only once?
*
* @var bool
*/
protected $migrateOnce = false;
/**
* Should run seeding only once?
*
* @var bool
*/
protected $seedOnce = false;
/**
* Should the db be refreshed before test?
*
* @var bool
*/
protected $refresh = true;
/**
* The seed file(s) used for all tests within this test case.
* Should be fully-namespaced or relative to $basePath
*
* @var array|string
*/
protected $seed = '';
/**
* The path to the seeds directory.
* Allows overriding the default application directories.
*
* @var string
*/
protected $basePath = SUPPORTPATH . 'Database';
/**
* The namespace(s) to help us find the migration classes.
* Empty is equivalent to running `spark migrate -all`.
* Note that running "all" runs migrations in date order,
* but specifying namespaces runs them in namespace order (then date)
*
* @var array|string|null
*/
protected $namespace = 'Tests\Support';
/**
* The name of the database group to connect to.
* If not present, will use the defaultGroup.
*
* @var string
*/
protected $DBGroup = 'tests';
/**
* Our database connection.
*
* @var BaseConnection
*/
protected $db;
/**
* Migration Runner instance.
*
* @var MigrationRunner|mixed
*/
protected $migrations;
/**
* Seeder instance
*
* @var Seeder
*/
protected $seeder;
/**
* Stores information needed to remove any
* rows inserted via $this->hasInDatabase();
*
* @var array
*/
protected $insertCache = [];
//--------------------------------------------------------------------
// Feature Properties
//--------------------------------------------------------------------
/**
* If present, will override application
* routes when using call().
*
* @var RouteCollection|null
*/
protected $routes;
/**
* Values to be set in the SESSION global
* before running the test.
*
* @var array
*/
protected $session = [];
/**
* Enabled auto clean op buffer after request call
*
* @var bool
*/
protected $clean = true;
/**
* Custom request's headers
*
* @var array
*/
protected $headers = [];
/**
* Allows for formatting the request body to what
* the controller is going to expect
*
* @var string
*/
protected $bodyFormat = '';
/**
* Allows for directly setting the body to what
* it needs to be.
*
* @var mixed
*/
protected $requestBody = '';
//--------------------------------------------------------------------
// Staging
//--------------------------------------------------------------------
/**
* Load the helpers.
*/
public static function setUpBeforeClass(): void
{
parent::setUpBeforeClass();
helper(['url', 'test']);
}
protected function setUp(): void
{
parent::setUp();
if (! $this->app) {
$this->app = $this->createApplication();
}
foreach ($this->setUpMethods as $method) {
$this->{$method}();
}
// Check for the database trait
if (method_exists($this, 'setUpDatabase')) {
$this->setUpDatabase();
}
// Check for other trait methods
$this->callTraitMethods('setUp');
}
protected function tearDown(): void
{
parent::tearDown();
foreach ($this->tearDownMethods as $method) {
$this->{$method}();
}
// Check for the database trait
if (method_exists($this, 'tearDownDatabase')) {
$this->tearDownDatabase();
}
// Check for other trait methods
$this->callTraitMethods('tearDown');
}
/**
* Checks for traits with corresponding
* methods for setUp or tearDown.
*
* @param string $stage 'setUp' or 'tearDown'
*/
private function callTraitMethods(string $stage): void
{
if ($this->traits === null) {
$this->traits = class_uses_recursive($this);
}
foreach ($this->traits as $trait) {
$method = $stage . class_basename($trait);
if (method_exists($this, $method)) {
$this->{$method}();
}
}
}
//--------------------------------------------------------------------
// Mocking
//--------------------------------------------------------------------
/**
* Resets shared instanced for all Factories components
*/
protected function resetFactories()
{
Factories::reset();
}
/**
* Resets shared instanced for all Services
*/
protected function resetServices()
{
Services::reset();
}
/**
* Injects the mock Cache driver to prevent filesystem collisions
*/
protected function mockCache()
{
Services::injectMock('cache', new MockCache());
}
/**
* Injects the mock email driver so no emails really send
*/
protected function mockEmail()
{
Services::injectMock('email', new MockEmail(config('Email')));
}
/**
* Injects the mock session driver into Services
*/
protected function mockSession()
{
$_SESSION = [];
$config = config('App');
$session = new MockSession(new ArrayHandler($config, '0.0.0.0'), $config);
Services::injectMock('session', $session);
}
//--------------------------------------------------------------------
// Assertions
//--------------------------------------------------------------------
/**
* Custom function to hook into CodeIgniter's Logging mechanism
* to check if certain messages were logged during code execution.
*
* @param string|null $expectedMessage
*
* @throws Exception
*
* @return bool
*/
public function assertLogged(string $level, $expectedMessage = null)
{
$result = TestLogger::didLog($level, $expectedMessage);
$this->assertTrue($result, sprintf(
'Failed asserting that expected message "%s" with level "%s" was logged.',
$expectedMessage ?? '',
$level
));
return $result;
}
/**
* Hooks into CodeIgniter's Events system to check if a specific
* event was triggered or not.
*
* @throws Exception
*/
public function assertEventTriggered(string $eventName): bool
{
$found = false;
$eventName = strtolower($eventName);
foreach (Events::getPerformanceLogs() as $log) {
if ($log['event'] !== $eventName) {
continue;
}
$found = true;
break;
}
$this->assertTrue($found);
return $found;
}
/**
* Hooks into xdebug's headers capture, looking for a specific header
* emitted
*
* @param string $header The leading portion of the header we are looking for
*
* @throws Exception
*/
public function assertHeaderEmitted(string $header, bool $ignoreCase = false): void
{
$found = false;
if (! function_exists('xdebug_get_headers')) {
$this->markTestSkipped('XDebug not found.');
}
foreach (xdebug_get_headers() as $emitted) {
$found = $ignoreCase ?
(stripos($emitted, $header) === 0) :
(strpos($emitted, $header) === 0);
if ($found) {
break;
}
}
$this->assertTrue($found, "Didn't find header for {$header}");
}
/**
* Hooks into xdebug's headers capture, looking for a specific header
* emitted
*
* @param string $header The leading portion of the header we don't want to find
*
* @throws Exception
*/
public function assertHeaderNotEmitted(string $header, bool $ignoreCase = false): void
{
$found = false;
if (! function_exists('xdebug_get_headers')) {
$this->markTestSkipped('XDebug not found.');
}
foreach (xdebug_get_headers() as $emitted) {
$found = $ignoreCase ?
(stripos($emitted, $header) === 0) :
(strpos($emitted, $header) === 0);
if ($found) {
break;
}
}
$success = ! $found;
$this->assertTrue($success, "Found header for {$header}");
}
/**
* Custom function to test that two values are "close enough".
* This is intended for extended execution time testing,
* where the result is close but not exactly equal to the
* expected time, for reasons beyond our control.
*
* @param mixed $actual
*
* @throws Exception
*/
public function assertCloseEnough(int $expected, $actual, string $message = '', int $tolerance = 1)
{
$difference = abs($expected - (int) floor($actual));
$this->assertLessThanOrEqual($tolerance, $difference, $message);
}
/**
* Custom function to test that two values are "close enough".
* This is intended for extended execution time testing,
* where the result is close but not exactly equal to the
* expected time, for reasons beyond our control.
*
* @param mixed $expected
* @param mixed $actual
*
* @throws Exception
*
* @return bool|void
*/
public function assertCloseEnoughString($expected, $actual, string $message = '', int $tolerance = 1)
{
$expected = (string) $expected;
$actual = (string) $actual;
if (strlen($expected) !== strlen($actual)) {
return false;
}
try {
$expected = (int) substr($expected, -2);
$actual = (int) substr($actual, -2);
$difference = abs($expected - $actual);
$this->assertLessThanOrEqual($tolerance, $difference, $message);
} catch (Exception $e) {
return false;
}
}
//--------------------------------------------------------------------
// Utility
//--------------------------------------------------------------------
/**
* Loads up an instance of CodeIgniter
* and gets the environment setup.
*
* @return CodeIgniter
*/
protected function createApplication()
{
// Initialize the autoloader.
Services::autoloader()->initialize(new Autoload(), new Modules());
$app = new MockCodeIgniter(new App());
$app->initialize();
return $app;
}
/**
* Return first matching emitted header.
*
* @param string $header Identifier of the header of interest
*
* @return string|null The value of the header found, null if not found
*/
protected function getHeaderEmitted(string $header, bool $ignoreCase = false): ?string
{
if (! function_exists('xdebug_get_headers')) {
$this->markTestSkipped('XDebug not found.');
}
foreach (xdebug_get_headers() as $emitted) {
$found = $ignoreCase ?
(stripos($emitted, $header) === 0) :
(strpos($emitted, $header) === 0);
if ($found) {
return $emitted;
}
}
return null;
}
}

View File

@@ -0,0 +1,118 @@
<?php
/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace CodeIgniter\Test\Constraints;
use CodeIgniter\Database\ConnectionInterface;
use PHPUnit\Framework\Constraint\Constraint;
class SeeInDatabase extends Constraint
{
/**
* The number of results that will show in the database
* in case of failure.
*
* @var int
*/
protected $show = 3;
/**
* @var ConnectionInterface
*/
protected $db;
/**
* Data used to compare results against.
*
* @var array
*/
protected $data;
/**
* SeeInDatabase constructor.
*/
public function __construct(ConnectionInterface $db, array $data)
{
$this->db = $db;
$this->data = $data;
}
/**
* Check if data is found in the table
*
* @param mixed $table
*/
protected function matches($table): bool
{
return $this->db->table($table)->where($this->data)->countAllResults() > 0;
}
/**
* Get the description of the failure
*
* @param mixed $table
*/
protected function failureDescription($table): string
{
return sprintf(
"a row in the table [%s] matches the attributes \n%s\n\n%s",
$table,
$this->toString(JSON_PRETTY_PRINT),
$this->getAdditionalInfo($table)
);
}
/**
* Gets additional records similar to $data.
*/
protected function getAdditionalInfo(string $table): string
{
$builder = $this->db->table($table);
$similar = $builder->where(
array_key_first($this->data),
$this->data[array_key_first($this->data)]
)->limit($this->show)->get()->getResultArray();
if ($similar !== []) {
$description = 'Found similar results: ' . json_encode($similar, JSON_PRETTY_PRINT);
} else {
// Does the table have any results at all?
$results = $this->db->table($table)
->limit($this->show)
->get()
->getResultArray();
if ($results !== []) {
return 'The table is empty.';
}
$description = 'Found: ' . json_encode($results, JSON_PRETTY_PRINT);
}
$total = $this->db->table($table)->countAll();
if ($total > $this->show) {
$description .= sprintf(' and %s others', $total - $this->show);
}
return $description;
}
/**
* Gets a string representation of the constraint
*
* @param int $options
*/
public function toString($options = 0): string
{
return json_encode($this->data, $options);
}
}

View File

@@ -0,0 +1,99 @@
<?php
/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace CodeIgniter\Test;
use CodeIgniter\HTTP\ResponseInterface;
use Config\Services;
/**
* Testable response from a controller
*
* @deprecated Use TestResponse directly
*
* @codeCoverageIgnore
*/
class ControllerResponse extends TestResponse
{
/**
* The message payload.
*
* @var string
*
* @deprecated Use $response->getBody() instead
*/
protected $body;
/**
* DOM for the body.
*
* @var DOMParser
*
* @deprecated Use $domParser instead
*/
protected $dom;
/**
* Maintains the deprecated $dom property.
*/
public function __construct()
{
parent::__construct(Services::response());
$this->dom = &$this->domParser;
}
/**
* Sets the response.
*
* @return $this
*
* @deprecated Will revert to parent::setResponse() in a future release (no $body updates)
*/
public function setResponse(ResponseInterface $response)
{
parent::setResponse($response);
$this->body = $response->getBody() ?? '';
return $this;
}
/**
* Sets the body and updates the DOM.
*
* @return $this
*
* @deprecated Use response()->setBody() instead
*/
public function setBody(string $body)
{
$this->body = $body;
if ($body !== '') {
$this->domParser->withString($body);
}
return $this;
}
/**
* Retrieve the body.
*
* @return string
*
* @deprecated Use response()->getBody() instead
*/
public function getBody()
{
return $this->body;
}
}

View File

@@ -0,0 +1,297 @@
<?php
/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace CodeIgniter\Test;
use CodeIgniter\Controller;
use CodeIgniter\HTTP\Exceptions\HTTPException;
use CodeIgniter\HTTP\IncomingRequest;
use CodeIgniter\HTTP\Response;
use CodeIgniter\HTTP\URI;
use Config\App;
use Config\Services;
use InvalidArgumentException;
use Psr\Log\LoggerInterface;
use Throwable;
/**
* Controller Test Trait
*
* Provides features that make testing controllers simple and fluent.
*
* Example:
*
* $this->withRequest($request)
* ->withResponse($response)
* ->withURI($uri)
* ->withBody($body)
* ->controller('App\Controllers\Home')
* ->execute('methodName');
*/
trait ControllerTestTrait
{
/**
* Controller configuration.
*
* @var App
*/
protected $appConfig;
/**
* Request.
*
* @var IncomingRequest
*/
protected $request;
/**
* Response.
*
* @var Response
*/
protected $response;
/**
* Message logger.
*
* @var LoggerInterface
*/
protected $logger;
/**
* Initialized controller.
*
* @var Controller
*/
protected $controller;
/**
* URI of this request.
*
* @var string
*/
protected $uri = 'http://example.com';
/**
* Request body.
*
* @var string|null
*/
protected $body;
/**
* Initializes required components.
*/
protected function setUpControllerTestTrait(): void
{
// The URL helper is always loaded by the system so ensure it is available.
helper('url');
if (empty($this->appConfig)) {
$this->appConfig = config('App');
}
if (! $this->uri instanceof URI) {
$this->uri = Services::uri($this->appConfig->baseURL ?? 'http://example.com/', false);
}
if (empty($this->request)) {
// Do some acrobatics so we can use the Request service with our own URI
$tempUri = Services::uri();
Services::injectMock('uri', $this->uri);
$this->withRequest(Services::request($this->appConfig, false)->setBody($this->body));
// Restore the URI service
Services::injectMock('uri', $tempUri);
}
if (empty($this->response)) {
$this->response = Services::response($this->appConfig, false);
}
if (empty($this->logger)) {
$this->logger = Services::logger();
}
}
/**
* Loads the specified controller, and generates any needed dependencies.
*
* @return mixed
*/
public function controller(string $name)
{
if (! class_exists($name)) {
throw new InvalidArgumentException('Invalid Controller: ' . $name);
}
$this->controller = new $name();
$this->controller->initController($this->request, $this->response, $this->logger);
return $this;
}
/**
* Runs the specified method on the controller and returns the results.
*
* @param array $params
*
* @throws InvalidArgumentException
*
* @return TestResponse
*/
public function execute(string $method, ...$params)
{
if (! method_exists($this->controller, $method) || ! is_callable([$this->controller, $method])) {
throw new InvalidArgumentException('Method does not exist or is not callable in controller: ' . $method);
}
$response = null;
try {
ob_start();
$response = $this->controller->{$method}(...$params);
} catch (Throwable $e) {
$code = $e->getCode();
// If code is not a valid HTTP status then assume there is an error
if ($code < 100 || $code >= 600) {
throw $e;
}
} finally {
$output = ob_get_clean();
}
// If the controller returned a view then add it to the output
if (is_string($response)) {
$output = is_string($output) ? $output . $response : $response;
}
// If the controller did not return a response then start one
if (! $response instanceof Response) {
$response = $this->response;
}
// Check for output to set or prepend
// @see \CodeIgniter\CodeIgniter::gatherOutput()
if (is_string($output)) {
if (is_string($response->getBody())) {
$response->setBody($output . $response->getBody());
} else {
$response->setBody($output);
}
}
// Check for an overriding code from exceptions
if (isset($code)) {
$response->setStatusCode($code);
}
// Otherwise ensure there is a status code
else {
// getStatusCode() throws for empty codes
try {
$response->getStatusCode();
} catch (HTTPException $e) {
// If no code has been set then assume success
$response->setStatusCode(200);
}
}
// Create the result and add the Request for reference
return (new TestResponse($response))->setRequest($this->request);
}
/**
* Set controller's config, with method chaining.
*
* @param mixed $appConfig
*
* @return mixed
*/
public function withConfig($appConfig)
{
$this->appConfig = $appConfig;
return $this;
}
/**
* Set controller's request, with method chaining.
*
* @param mixed $request
*
* @return mixed
*/
public function withRequest($request)
{
$this->request = $request;
// Make sure it's available for other classes
Services::injectMock('request', $request);
return $this;
}
/**
* Set controller's response, with method chaining.
*
* @param mixed $response
*
* @return mixed
*/
public function withResponse($response)
{
$this->response = $response;
return $this;
}
/**
* Set controller's logger, with method chaining.
*
* @param mixed $logger
*
* @return mixed
*/
public function withLogger($logger)
{
$this->logger = $logger;
return $this;
}
/**
* Set the controller's URI, with method chaining.
*
* @return mixed
*/
public function withUri(string $uri)
{
$this->uri = new URI($uri);
return $this;
}
/**
* Set the method's body, with method chaining.
*
* @param string|null $body
*
* @return mixed
*/
public function withBody($body)
{
$this->body = $body;
return $this;
}
}

View File

@@ -0,0 +1,293 @@
<?php
/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace CodeIgniter\Test;
use CodeIgniter\Controller;
use CodeIgniter\HTTP\IncomingRequest;
use CodeIgniter\HTTP\Response;
use CodeIgniter\HTTP\URI;
use Config\App;
use Config\Services;
use InvalidArgumentException;
use Psr\Log\LoggerInterface;
use Throwable;
/**
* ControllerTester Trait
*
* Provides features that make testing controllers simple and fluent.
*
* Example:
*
* $this->withRequest($request)
* ->withResponse($response)
* ->withURI($uri)
* ->withBody($body)
* ->controller('App\Controllers\Home')
* ->execute('methodName');
*
* @deprecated Use ControllerTestTrait instead
*
* @codeCoverageIgnore
*/
trait ControllerTester
{
/**
* Controller configuration.
*
* @var App
*/
protected $appConfig;
/**
* Request.
*
* @var IncomingRequest
*/
protected $request;
/**
* Response.
*
* @var Response
*/
protected $response;
/**
* Message logger.
*
* @var LoggerInterface
*/
protected $logger;
/**
* Initialized controller.
*
* @var Controller
*/
protected $controller;
/**
* URI of this request.
*
* @var string
*/
protected $uri = 'http://example.com';
/**
* Request or response body.
*
* @var string|null
*/
protected $body;
/**
* Initializes required components.
*/
protected function setUpControllerTester(): void
{
if (empty($this->appConfig)) {
$this->appConfig = config('App');
}
if (! $this->uri instanceof URI) {
$this->uri = Services::uri($this->appConfig->baseURL ?? 'http://example.com/', false);
}
if (empty($this->request)) {
// Do some acrobatics so we can use the Request service with our own URI
$tempUri = Services::uri();
Services::injectMock('uri', $this->uri);
$this->withRequest(Services::request($this->appConfig, false)->setBody($this->body));
// Restore the URI service
Services::injectMock('uri', $tempUri);
}
if (empty($this->response)) {
$this->response = Services::response($this->appConfig, false);
}
if (empty($this->logger)) {
$this->logger = Services::logger();
}
}
/**
* Loads the specified controller, and generates any needed dependencies.
*
* @return mixed
*/
public function controller(string $name)
{
if (! class_exists($name)) {
throw new InvalidArgumentException('Invalid Controller: ' . $name);
}
$this->controller = new $name();
$this->controller->initController($this->request, $this->response, $this->logger);
return $this;
}
/**
* Runs the specified method on the controller and returns the results.
*
* @param array $params
*
* @throws InvalidArgumentException
*
* @return ControllerResponse
*/
public function execute(string $method, ...$params)
{
if (! method_exists($this->controller, $method) || ! is_callable([$this->controller, $method])) {
throw new InvalidArgumentException('Method does not exist or is not callable in controller: ' . $method);
}
// The URL helper is always loaded by the system
// so ensure it's available.
helper('url');
$result = (new ControllerResponse())
->setRequest($this->request)
->setResponse($this->response);
$response = null;
try {
ob_start();
$response = $this->controller->{$method}(...$params);
} catch (Throwable $e) {
$code = $e->getCode();
// If code is not a valid HTTP status then assume there is an error
if ($code < 100 || $code >= 600) {
throw $e;
}
$result->response()->setStatusCode($code);
} finally {
$output = ob_get_clean();
// If the controller returned a response, use it
if (isset($response) && $response instanceof Response) {
$result->setResponse($response);
}
// check if controller returned a view rather than echoing it
if (is_string($response)) {
$output = $response;
$result->response()->setBody($output);
$result->setBody($output);
} elseif (! empty($response) && ! empty($response->getBody())) {
$result->setBody($response->getBody());
} else {
$result->setBody('');
}
}
// If not response code has been sent, assume a success
if (empty($result->response()->getStatusCode())) {
$result->response()->setStatusCode(200);
}
return $result;
}
/**
* Set controller's config, with method chaining.
*
* @param mixed $appConfig
*
* @return mixed
*/
public function withConfig($appConfig)
{
$this->appConfig = $appConfig;
return $this;
}
/**
* Set controller's request, with method chaining.
*
* @param mixed $request
*
* @return mixed
*/
public function withRequest($request)
{
$this->request = $request;
// Make sure it's available for other classes
Services::injectMock('request', $request);
return $this;
}
/**
* Set controller's response, with method chaining.
*
* @param mixed $response
*
* @return mixed
*/
public function withResponse($response)
{
$this->response = $response;
return $this;
}
/**
* Set controller's logger, with method chaining.
*
* @param mixed $logger
*
* @return mixed
*/
public function withLogger($logger)
{
$this->logger = $logger;
return $this;
}
/**
* Set the controller's URI, with method chaining.
*
* @return mixed
*/
public function withUri(string $uri)
{
$this->uri = new URI($uri);
return $this;
}
/**
* Set the method's body, with method chaining.
*
* @param string|null $body
*
* @return mixed
*/
public function withBody($body)
{
$this->body = $body;
return $this;
}
}

283
system/Test/DOMParser.php Normal file
View File

@@ -0,0 +1,283 @@
<?php
/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace CodeIgniter\Test;
use BadMethodCallException;
use DOMDocument;
use DOMNodeList;
use DOMXPath;
use InvalidArgumentException;
/**
* Load a response into a DOMDocument for testing assertions based on that
*/
class DOMParser
{
/**
* DOM for the body,
*
* @var DOMDocument
*/
protected $dom;
/**
* Constructor.
*
* @throws BadMethodCallException
*/
public function __construct()
{
if (! extension_loaded('DOM')) {
// always there in travis-ci
// @codeCoverageIgnoreStart
throw new BadMethodCallException('DOM extension is required, but not currently loaded.');
// @codeCoverageIgnoreEnd
}
$this->dom = new DOMDocument('1.0', 'utf-8');
}
/**
* Returns the body of the current document.
*/
public function getBody(): string
{
return $this->dom->saveHTML();
}
/**
* Sets a string as the body that we want to work with.
*
* @return $this
*/
public function withString(string $content)
{
// converts all special characters to utf-8
$content = mb_convert_encoding($content, 'HTML-ENTITIES', 'UTF-8');
// turning off some errors
libxml_use_internal_errors(true);
if (! $this->dom->loadHTML($content)) {
// unclear how we would get here, given that we are trapping libxml errors
// @codeCoverageIgnoreStart
libxml_clear_errors();
throw new BadMethodCallException('Invalid HTML');
// @codeCoverageIgnoreEnd
}
// ignore the whitespace.
$this->dom->preserveWhiteSpace = false;
return $this;
}
/**
* Loads the contents of a file as a string
* so that we can work with it.
*
* @return DOMParser
*/
public function withFile(string $path)
{
if (! is_file($path)) {
throw new InvalidArgumentException(basename($path) . ' is not a valid file.');
}
$content = file_get_contents($path);
return $this->withString($content);
}
/**
* Checks to see if the text is found within the result.
*
* @param string $search
* @param string $element
*/
public function see(?string $search = null, ?string $element = null): bool
{
// If Element is null, we're just scanning for text
if ($element === null) {
$content = $this->dom->saveHTML($this->dom->documentElement);
return mb_strpos($content, $search) !== false;
}
$result = $this->doXPath($search, $element);
return (bool) $result->length;
}
/**
* Checks to see if the text is NOT found within the result.
*
* @param string $search
*/
public function dontSee(?string $search = null, ?string $element = null): bool
{
return ! $this->see($search, $element);
}
/**
* Checks to see if an element with the matching CSS specifier
* is found within the current DOM.
*/
public function seeElement(string $element): bool
{
return $this->see(null, $element);
}
/**
* Checks to see if the element is available within the result.
*/
public function dontSeeElement(string $element): bool
{
return $this->dontSee(null, $element);
}
/**
* Determines if a link with the specified text is found
* within the results.
*/
public function seeLink(string $text, ?string $details = null): bool
{
return $this->see($text, 'a' . $details);
}
/**
* Checks for an input named $field with a value of $value.
*/
public function seeInField(string $field, string $value): bool
{
$result = $this->doXPath(null, 'input', ["[@value=\"{$value}\"][@name=\"{$field}\"]"]);
return (bool) $result->length;
}
/**
* Checks for checkboxes that are currently checked.
*/
public function seeCheckboxIsChecked(string $element): bool
{
$result = $this->doXPath(null, 'input' . $element, [
'[@type="checkbox"]',
'[@checked="checked"]',
]);
return (bool) $result->length;
}
/**
* Search the DOM using an XPath expression.
*
* @return DOMNodeList
*/
protected function doXPath(?string $search, string $element, array $paths = [])
{
// Otherwise, grab any elements that match
// the selector
$selector = $this->parseSelector($element);
$path = '';
// By ID
if (! empty($selector['id'])) {
$path = empty($selector['tag'])
? "id(\"{$selector['id']}\")"
: "//{$selector['tag']}[@id=\"{$selector['id']}\"]";
}
// By Class
elseif (! empty($selector['class'])) {
$path = empty($selector['tag'])
? "//*[@class=\"{$selector['class']}\"]"
: "//{$selector['tag']}[@class=\"{$selector['class']}\"]";
}
// By tag only
elseif (! empty($selector['tag'])) {
$path = "//{$selector['tag']}";
}
if (! empty($selector['attr'])) {
foreach ($selector['attr'] as $key => $value) {
$path .= "[@{$key}=\"{$value}\"]";
}
}
// $paths might contain a number of different
// ready to go xpath portions to tack on.
if (! empty($paths) && is_array($paths)) {
foreach ($paths as $extra) {
$path .= $extra;
}
}
if ($search !== null) {
$path .= "[contains(., \"{$search}\")]";
}
$xpath = new DOMXPath($this->dom);
return $xpath->query($path);
}
/**
* Look for the a selector in the passed text.
*
* @return array
*/
public function parseSelector(string $selector)
{
$id = null;
$class = null;
$attr = null;
// ID?
if (strpos($selector, '#') !== false) {
[$tag, $id] = explode('#', $selector);
}
// Attribute
elseif (strpos($selector, '[') !== false && strpos($selector, ']') !== false) {
$open = strpos($selector, '[');
$close = strpos($selector, ']');
$tag = substr($selector, 0, $open);
$text = substr($selector, $open + 1, $close - 2);
// We only support a single attribute currently
$text = explode(',', $text);
$text = trim(array_shift($text));
[$name, $value] = explode('=', $text);
$name = trim($name);
$value = trim($value);
$attr = [$name => trim($value, '] ')];
}
// Class?
elseif (strpos($selector, '.') !== false) {
[$tag, $class] = explode('.', $selector);
}
// Otherwise, assume the entire string is our tag
else {
$tag = $selector;
}
return [
'tag' => $tag,
'id' => $id,
'class' => $class,
'attr' => $attr,
];
}
}

View File

@@ -0,0 +1,326 @@
<?php
/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace CodeIgniter\Test;
use CodeIgniter\Database\BaseBuilder;
use CodeIgniter\Database\Exceptions\DatabaseException;
use CodeIgniter\Test\Constraints\SeeInDatabase;
use Config\Database;
use Config\Migrations;
use Config\Services;
/**
* DatabaseTestTrait
*
* Provides functionality for refreshing/seeding
* the database during testing.
*
* @mixin CIUnitTestCase
*/
trait DatabaseTestTrait
{
/**
* Is db migration done once or more than once?
*
* @var bool
*/
private static $doneMigration = false;
/**
* Is seeding done once or more than once?
*
* @var bool
*/
private static $doneSeed = false;
//--------------------------------------------------------------------
// Staging
//--------------------------------------------------------------------
/**
* Runs the trait set up methods.
*/
protected function setUpDatabase()
{
$this->loadDependencies();
$this->setUpMigrate();
$this->setUpSeed();
}
/**
* Runs the trait set up methods.
*/
protected function tearDownDatabase()
{
$this->clearInsertCache();
}
/**
* Load any database test dependencies.
*/
public function loadDependencies()
{
if ($this->db === null) {
$this->db = Database::connect($this->DBGroup);
$this->db->initialize();
}
if ($this->migrations === null) {
// Ensure that we can run migrations
$config = new Migrations();
$config->enabled = true;
$this->migrations = Services::migrations($config, $this->db);
$this->migrations->setSilent(false);
}
if ($this->seeder === null) {
$this->seeder = Database::seeder($this->DBGroup);
$this->seeder->setSilent(true);
}
}
//--------------------------------------------------------------------
// Migrations
//--------------------------------------------------------------------
/**
* Migrate on setUp
*/
protected function setUpMigrate()
{
if ($this->migrateOnce === false || self::$doneMigration === false) {
if ($this->refresh === true) {
$this->regressDatabase();
// Reset counts on faked items
Fabricator::resetCounts();
}
$this->migrateDatabase();
}
}
/**
* Regress migrations as defined by the class
*/
protected function regressDatabase()
{
if ($this->migrate === false) {
return;
}
// If no namespace was specified then rollback all
if (empty($this->namespace)) {
$this->migrations->setNamespace(null);
$this->migrations->regress(0, 'tests');
}
// Regress each specified namespace
else {
$namespaces = is_array($this->namespace) ? $this->namespace : [$this->namespace];
foreach ($namespaces as $namespace) {
$this->migrations->setNamespace($namespace);
$this->migrations->regress(0, 'tests');
}
}
}
/**
* Run migrations as defined by the class
*/
protected function migrateDatabase()
{
if ($this->migrate === false) {
return;
}
// If no namespace was specified then migrate all
if (empty($this->namespace)) {
$this->migrations->setNamespace(null);
$this->migrations->latest('tests');
self::$doneMigration = true;
}
// Run migrations for each specified namespace
else {
$namespaces = is_array($this->namespace) ? $this->namespace : [$this->namespace];
foreach ($namespaces as $namespace) {
$this->migrations->setNamespace($namespace);
$this->migrations->latest('tests');
self::$doneMigration = true;
}
}
}
//--------------------------------------------------------------------
// Seeds
//--------------------------------------------------------------------
/**
* Seed on setUp
*/
protected function setUpSeed()
{
if ($this->seedOnce === false || self::$doneSeed === false) {
$this->runSeeds();
}
}
/**
* Run seeds as defined by the class
*/
protected function runSeeds()
{
if (! empty($this->seed)) {
if (! empty($this->basePath)) {
$this->seeder->setPath(rtrim($this->basePath, '/') . '/Seeds');
}
$seeds = is_array($this->seed) ? $this->seed : [$this->seed];
foreach ($seeds as $seed) {
$this->seed($seed);
}
}
self::$doneSeed = true;
}
/**
* Seeds that database with a specific seeder.
*/
public function seed(string $name)
{
$this->seeder->call($name);
}
//--------------------------------------------------------------------
// Utility
//--------------------------------------------------------------------
/**
* Reset $doneMigration and $doneSeed
*
* @afterClass
*/
public static function resetMigrationSeedCount()
{
self::$doneMigration = false;
self::$doneSeed = false;
}
/**
* Removes any rows inserted via $this->hasInDatabase()
*/
protected function clearInsertCache()
{
foreach ($this->insertCache as $row) {
$this->db->table($row[0])
->where($row[1])
->delete();
}
}
/**
* Loads the Builder class appropriate for the current database.
*
* @return BaseBuilder
*/
public function loadBuilder(string $tableName)
{
$builderClass = str_replace('Connection', 'Builder', get_class($this->db));
return new $builderClass($tableName, $this->db);
}
/**
* Fetches a single column from a database row with criteria
* matching $where.
*
* @throws DatabaseException
*
* @return bool
*/
public function grabFromDatabase(string $table, string $column, array $where)
{
$query = $this->db->table($table)
->select($column)
->where($where)
->get();
$query = $query->getRow();
return $query->{$column} ?? false;
}
//--------------------------------------------------------------------
// Assertions
//--------------------------------------------------------------------
/**
* Asserts that records that match the conditions in $where DO
* exist in the database.
*
* @throws DatabaseException
*/
public function seeInDatabase(string $table, array $where)
{
$constraint = new SeeInDatabase($this->db, $where);
static::assertThat($table, $constraint);
}
/**
* Asserts that records that match the conditions in $where do
* not exist in the database.
*/
public function dontSeeInDatabase(string $table, array $where)
{
$count = $this->db->table($table)
->where($where)
->countAllResults();
$this->assertTrue($count === 0, 'Row was found in database');
}
/**
* Inserts a row into to the database. This row will be removed
* after the test has run.
*
* @return bool
*/
public function hasInDatabase(string $table, array $data)
{
$this->insertCache[] = [
$table,
$data,
];
return $this->db->table($table)->insert($data);
}
/**
* Asserts that the number of rows in the database that match $where
* is equal to $expected.
*
* @throws DatabaseException
*/
public function seeNumRecords(int $expected, string $table, array $where)
{
$count = $this->db->table($table)
->where($where)
->countAllResults();
$this->assertEquals($expected, $count, 'Wrong number of matching rows in database.');
}
}

542
system/Test/Fabricator.php Normal file
View File

@@ -0,0 +1,542 @@
<?php
/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace CodeIgniter\Test;
use CodeIgniter\Exceptions\FrameworkException;
use CodeIgniter\Model;
use Faker\Factory;
use Faker\Generator;
use InvalidArgumentException;
use RuntimeException;
/**
* Fabricator
*
* Bridge class for using Faker to create example data based on
* model specifications.
*/
class Fabricator
{
/**
* Array of counts for fabricated items
*
* @var array
*/
protected static $tableCounts = [];
/**
* Locale-specific Faker instance
*
* @var Generator
*/
protected $faker;
/**
* Model instance (can be non-framework if it follows framework design)
*
* @var Model|object
*/
protected $model;
/**
* Locale used to initialize Faker
*
* @var string
*/
protected $locale;
/**
* Map of properties and their formatter to use
*
* @var array|null
*/
protected $formatters;
/**
* Date fields present in the model
*
* @var array
*/
protected $dateFields = [];
/**
* Array of data to add or override faked versions
*
* @var array
*/
protected $overrides = [];
/**
* Array of single-use data to override faked versions
*
* @var array|null
*/
protected $tempOverrides;
/**
* Default formatter to use when nothing is detected
*
* @var string
*/
public $defaultFormatter = 'word';
/**
* Store the model instance and initialize Faker to the locale.
*
* @param object|string $model Instance or classname of the model to use
* @param array|null $formatters Array of property => formatter
* @param string|null $locale Locale for Faker provider
*
* @throws InvalidArgumentException
*/
public function __construct($model, ?array $formatters = null, ?string $locale = null)
{
if (is_string($model)) {
// Create a new model instance
$model = model($model, false);
}
if (! is_object($model)) {
throw new InvalidArgumentException(lang('Fabricator.invalidModel'));
}
$this->model = $model;
// If no locale was specified then use the App default
if ($locale === null) {
$locale = config('App')->defaultLocale;
}
// There is no easy way to retrieve the locale from Faker so we will store it
$this->locale = $locale;
// Create the locale-specific Generator
$this->faker = Factory::create($this->locale);
// Determine eligible date fields
foreach (['createdField', 'updatedField', 'deletedField'] as $field) {
if (! empty($this->model->{$field})) {
$this->dateFields[] = $this->model->{$field};
}
}
// Set the formatters
$this->setFormatters($formatters);
}
/**
* Reset internal counts
*/
public static function resetCounts()
{
self::$tableCounts = [];
}
/**
* Get the count for a specific table
*
* @param string $table Name of the target table
*/
public static function getCount(string $table): int
{
return empty(self::$tableCounts[$table]) ? 0 : self::$tableCounts[$table];
}
/**
* Set the count for a specific table
*
* @param string $table Name of the target table
* @param int $count Count value
*
* @return int The new count value
*/
public static function setCount(string $table, int $count): int
{
self::$tableCounts[$table] = $count;
return $count;
}
/**
* Increment the count for a table
*
* @param string $table Name of the target table
*
* @return int The new count value
*/
public static function upCount(string $table): int
{
return self::setCount($table, self::getCount($table) + 1);
}
/**
* Decrement the count for a table
*
* @param string $table Name of the target table
*
* @return int The new count value
*/
public static function downCount(string $table): int
{
return self::setCount($table, self::getCount($table) - 1);
}
/**
* Returns the model instance
*
* @return object Framework or compatible model
*/
public function getModel()
{
return $this->model;
}
/**
* Returns the locale
*/
public function getLocale(): string
{
return $this->locale;
}
/**
* Returns the Faker generator
*/
public function getFaker(): Generator
{
return $this->faker;
}
/**
* Return and reset tempOverrides
*/
public function getOverrides(): array
{
$overrides = $this->tempOverrides ?? $this->overrides;
$this->tempOverrides = $this->overrides;
return $overrides;
}
/**
* Set the overrides, once or persistent
*
* @param array $overrides Array of [field => value]
* @param bool $persist Whether these overrides should persist through the next operation
*/
public function setOverrides(array $overrides = [], $persist = true): self
{
if ($persist) {
$this->overrides = $overrides;
}
$this->tempOverrides = $overrides;
return $this;
}
/**
* Returns the current formatters
*/
public function getFormatters(): ?array
{
return $this->formatters;
}
/**
* Set the formatters to use. Will attempt to autodetect if none are available.
*
* @param array|null $formatters Array of [field => formatter], or null to detect
*/
public function setFormatters(?array $formatters = null): self
{
if ($formatters !== null) {
$this->formatters = $formatters;
} elseif (method_exists($this->model, 'fake')) {
$this->formatters = null;
} else {
$this->detectFormatters();
}
return $this;
}
/**
* Try to identify the appropriate Faker formatter for each field.
*/
protected function detectFormatters(): self
{
$this->formatters = [];
if (! empty($this->model->allowedFields)) {
foreach ($this->model->allowedFields as $field) {
$this->formatters[$field] = $this->guessFormatter($field);
}
}
return $this;
}
/**
* Guess at the correct formatter to match a field name.
*
* @param string $field Name of the field
*
* @return string Name of the formatter
*/
protected function guessFormatter($field): string
{
// First check for a Faker formatter of the same name - covers things like "email"
try {
$this->faker->getFormatter($field);
return $field;
} catch (InvalidArgumentException $e) {
// No match, keep going
}
// Next look for known model fields
if (in_array($field, $this->dateFields, true)) {
switch ($this->model->dateFormat) {
case 'datetime':
case 'date':
return 'date';
case 'int':
return 'unixTime';
}
} elseif ($field === $this->model->primaryKey) {
return 'numberBetween';
}
// Check some common partials
foreach (['email', 'name', 'title', 'text', 'date', 'url'] as $term) {
if (stripos($field, $term) !== false) {
return $term;
}
}
if (stripos($field, 'phone') !== false) {
return 'phoneNumber';
}
// Nothing left, use the default
return $this->defaultFormatter;
}
/**
* Generate new entities with faked data
*
* @param int|null $count Optional number to create a collection
*
* @return array|object An array or object (based on returnType), or an array of returnTypes
*/
public function make(?int $count = null)
{
// If a singleton was requested then go straight to it
if ($count === null) {
return $this->model->returnType === 'array'
? $this->makeArray()
: $this->makeObject();
}
$return = [];
for ($i = 0; $i < $count; $i++) {
$return[] = $this->model->returnType === 'array'
? $this->makeArray()
: $this->makeObject();
}
return $return;
}
/**
* Generate an array of faked data
*
* @throws RuntimeException
*
* @return array An array of faked data
*/
public function makeArray()
{
if ($this->formatters !== null) {
$result = [];
foreach ($this->formatters as $field => $formatter) {
$result[$field] = $this->faker->{$formatter};
}
}
// If no formatters were defined then look for a model fake() method
elseif (method_exists($this->model, 'fake')) {
$result = $this->model->fake($this->faker);
$result = is_object($result) && method_exists($result, 'toArray')
// This should cover entities
? $result->toArray()
// Try to cast it
: (array) $result;
}
// Nothing left to do but give up
else {
throw new RuntimeException(lang('Fabricator.missingFormatters'));
}
// Replace overridden fields
return array_merge($result, $this->getOverrides());
}
/**
* Generate an object of faked data
*
* @param string|null $className Class name of the object to create; null to use model default
*
* @throws RuntimeException
*
* @return object An instance of the class with faked data
*/
public function makeObject(?string $className = null): object
{
if ($className === null) {
if ($this->model->returnType === 'object' || $this->model->returnType === 'array') {
$className = 'stdClass';
} else {
$className = $this->model->returnType;
}
}
// If using the model's fake() method then check it for the correct return type
if ($this->formatters === null && method_exists($this->model, 'fake')) {
$result = $this->model->fake($this->faker);
if ($result instanceof $className) {
// Set overrides manually
foreach ($this->getOverrides() as $key => $value) {
$result->{$key} = $value;
}
return $result;
}
}
// Get the array values and apply them to the object
$array = $this->makeArray();
$object = new $className();
// Check for the entity method
if (method_exists($object, 'fill')) {
$object->fill($array);
} else {
foreach ($array as $key => $value) {
$object->{$key} = $value;
}
}
return $object;
}
/**
* Generate new entities from the database
*
* @param int|null $count Optional number to create a collection
* @param bool $mock Whether to execute or mock the insertion
*
* @throws FrameworkException
*
* @return array|object An array or object (based on returnType), or an array of returnTypes
*/
public function create(?int $count = null, bool $mock = false)
{
// Intercept mock requests
if ($mock) {
return $this->createMock($count);
}
$ids = [];
// Iterate over new entities and insert each one, storing insert IDs
foreach ($this->make($count ?? 1) as $result) {
if ($id = $this->model->insert($result, true)) {
$ids[] = $id;
self::upCount($this->model->table);
continue;
}
throw FrameworkException::forFabricatorCreateFailed($this->model->table, implode(' ', $this->model->errors() ?? []));
}
// If the model defines a "withDeleted" method for handling soft deletes then use it
if (method_exists($this->model, 'withDeleted')) {
$this->model->withDeleted();
}
return $this->model->find($count === null ? reset($ids) : $ids);
}
/**
* Generate new database entities without actually inserting them
*
* @param int|null $count Optional number to create a collection
*
* @return array|object An array or object (based on returnType), or an array of returnTypes
*/
protected function createMock(?int $count = null)
{
switch ($this->model->dateFormat) {
case 'datetime':
$datetime = date('Y-m-d H:i:s');
break;
case 'date':
$datetime = date('Y-m-d');
break;
default:
$datetime = time();
}
// Determine which fields we will need
$fields = [];
if (! empty($this->model->useTimestamps)) {
$fields[$this->model->createdField] = $datetime;
$fields[$this->model->updatedField] = $datetime;
}
if (! empty($this->model->useSoftDeletes)) {
$fields[$this->model->deletedField] = null;
}
// Iterate over new entities and add the necessary fields
$return = [];
foreach ($this->make($count ?? 1) as $i => $result) {
// Set the ID
$fields[$this->model->primaryKey] = $i;
// Merge fields
if (is_array($result)) {
$result = array_merge($result, $fields);
} else {
foreach ($fields as $key => $value) {
$result->{$key} = $value;
}
}
$return[] = $result;
}
return $count === null ? reset($return) : $return;
}
}

View File

@@ -0,0 +1,25 @@
<?php
/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace CodeIgniter\Test;
/**
* Assertions for a response
*
* @deprecated Use TestResponse directly
*/
class FeatureResponse extends TestResponse
{
/**
* @deprecated Will be protected in a future release, use response() instead
*/
public $response;
}

View File

@@ -0,0 +1,401 @@
<?php
/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace CodeIgniter\Test;
use CodeIgniter\Events\Events;
use CodeIgniter\HTTP\IncomingRequest;
use CodeIgniter\HTTP\Request;
use CodeIgniter\HTTP\URI;
use CodeIgniter\HTTP\UserAgent;
use CodeIgniter\Router\Exceptions\RedirectException;
use CodeIgniter\Router\RouteCollection;
use Config\App;
use Config\Services;
use Exception;
use ReflectionException;
/**
* Class FeatureTestCase
*
* Provides a base class with the trait for doing full HTTP testing
* against your application.
*
* @no-final
*
* @deprecated Use FeatureTestTrait instead
*
* @codeCoverageIgnore
*
* @internal
*/
class FeatureTestCase extends CIUnitTestCase
{
use DatabaseTestTrait;
/**
* Sets a RouteCollection that will override
* the application's route collection.
*
* Example routes:
* [
* ['get', 'home', 'Home::index']
* ]
*
* @param array $routes
*
* @return $this
*/
protected function withRoutes(?array $routes = null)
{
$collection = Services::routes();
if ($routes) {
$collection->resetRoutes();
foreach ($routes as $route) {
$collection->{$route[0]}($route[1], $route[2]);
}
}
$this->routes = $collection;
return $this;
}
/**
* Sets any values that should exist during this session.
*
* @param array|null $values Array of values, or null to use the current $_SESSION
*
* @return $this
*/
public function withSession(?array $values = null)
{
$this->session = $values ?? $_SESSION;
return $this;
}
/**
* Set request's headers
*
* Example of use
* withHeaders([
* 'Authorization' => 'Token'
* ])
*
* @param array $headers Array of headers
*
* @return $this
*/
public function withHeaders(array $headers = [])
{
$this->headers = $headers;
return $this;
}
/**
* Set the format the request's body should have.
*
* @param string $format The desired format. Currently supported formats: xml, json
*
* @return $this
*/
public function withBodyFormat(string $format)
{
$this->bodyFormat = $format;
return $this;
}
/**
* Set the raw body for the request
*
* @param mixed $body
*
* @return $this
*/
public function withBody($body)
{
$this->requestBody = $body;
return $this;
}
/**
* Don't run any events while running this test.
*
* @return $this
*/
public function skipEvents()
{
Events::simulate(true);
return $this;
}
/**
* Calls a single URI, executes it, and returns a FeatureResponse
* instance that can be used to run many assertions against.
*
* @throws Exception
* @throws RedirectException
*
* @return FeatureResponse
*/
public function call(string $method, string $path, ?array $params = null)
{
$buffer = \ob_get_level();
// Clean up any open output buffers
// not relevant to unit testing
// @codeCoverageIgnoreStart
if (\ob_get_level() > 0 && (! isset($this->clean) || $this->clean === true)) {
\ob_end_clean();
}
// @codeCoverageIgnoreEnd
// Simulate having a blank session
$_SESSION = [];
$_SERVER['REQUEST_METHOD'] = $method;
$request = $this->setupRequest($method, $path);
$request = $this->setupHeaders($request);
$request = $this->populateGlobals($method, $request, $params);
$request = $this->setRequestBody($request);
// Initialize the RouteCollection
if (! $routes = $this->routes) {
require APPPATH . 'Config/Routes.php';
/**
* @var RouteCollection $routes
*/
$routes->getRoutes('*');
}
$routes->setHTTPVerb($method);
// Make sure any other classes that might call the request
// instance get the right one.
Services::injectMock('request', $request);
// Make sure filters are reset between tests
Services::injectMock('filters', Services::filters(null, false));
$response = $this->app
->setRequest($request)
->run($routes, true);
$output = \ob_get_contents();
if (empty($response->getBody()) && ! empty($output)) {
$response->setBody($output);
}
// Reset directory if it has been set
Services::router()->setDirectory(null);
// Ensure the output buffer is identical so no tests are risky
// @codeCoverageIgnoreStart
while (\ob_get_level() > $buffer) {
\ob_end_clean();
}
while (\ob_get_level() < $buffer) {
\ob_start();
}
// @codeCoverageIgnoreEnd
return new FeatureResponse($response);
}
/**
* Performs a GET request.
*
* @throws Exception
* @throws RedirectException
*
* @return FeatureResponse
*/
public function get(string $path, ?array $params = null)
{
return $this->call('get', $path, $params);
}
/**
* Performs a POST request.
*
* @throws Exception
* @throws RedirectException
*
* @return FeatureResponse
*/
public function post(string $path, ?array $params = null)
{
return $this->call('post', $path, $params);
}
/**
* Performs a PUT request
*
* @throws Exception
* @throws RedirectException
*
* @return FeatureResponse
*/
public function put(string $path, ?array $params = null)
{
return $this->call('put', $path, $params);
}
/**
* Performss a PATCH request
*
* @throws Exception
* @throws RedirectException
*
* @return FeatureResponse
*/
public function patch(string $path, ?array $params = null)
{
return $this->call('patch', $path, $params);
}
/**
* Performs a DELETE request.
*
* @throws Exception
* @throws RedirectException
*
* @return FeatureResponse
*/
public function delete(string $path, ?array $params = null)
{
return $this->call('delete', $path, $params);
}
/**
* Performs an OPTIONS request.
*
* @throws Exception
* @throws RedirectException
*
* @return FeatureResponse
*/
public function options(string $path, ?array $params = null)
{
return $this->call('options', $path, $params);
}
/**
* Setup a Request object to use so that CodeIgniter
* won't try to auto-populate some of the items.
*/
protected function setupRequest(string $method, ?string $path = null): IncomingRequest
{
$config = config(App::class);
$uri = new URI(rtrim($config->baseURL, '/') . '/' . trim($path, '/ '));
$request = new IncomingRequest($config, clone $uri, null, new UserAgent());
$request->uri = $uri;
$request->setMethod($method);
$request->setProtocolVersion('1.1');
if ($config->forceGlobalSecureRequests) {
$_SERVER['HTTPS'] = 'test';
}
return $request;
}
/**
* Setup the custom request's headers
*
* @return IncomingRequest
*/
protected function setupHeaders(IncomingRequest $request)
{
foreach ($this->headers as $name => $value) {
$request->setHeader($name, $value);
}
return $request;
}
/**
* Populates the data of our Request with "global" data
* relevant to the request, like $_POST data.
*
* Always populate the GET vars based on the URI.
*
* @throws ReflectionException
*
* @return Request
*/
protected function populateGlobals(string $method, Request $request, ?array $params = null)
{
// $params should set the query vars if present,
// otherwise set it from the URL.
$get = ! empty($params) && $method === 'get'
? $params
: $this->getPrivateProperty($request->uri, 'query');
$request->setGlobal('get', $get);
if ($method !== 'get') {
$request->setGlobal($method, $params);
}
$request->setGlobal('request', $params);
$_SESSION = $this->session ?? [];
return $request;
}
/**
* Set the request's body formatted according to the value in $this->bodyFormat.
* This allows the body to be formatted in a way that the controller is going to
* expect as in the case of testing a JSON or XML API.
*
* @param array|null $params The parameters to be formatted and put in the body. If this is empty, it will get the
* what has been loaded into the request global of the request class.
*/
protected function setRequestBody(Request $request, ?array $params = null): Request
{
if (isset($this->requestBody) && $this->requestBody !== '') {
$request->setBody($this->requestBody);
return $request;
}
if (isset($this->bodyFormat) && $this->bodyFormat !== '') {
if (empty($params)) {
$params = $request->fetchGlobal('request');
}
$formatMime = '';
if ($this->bodyFormat === 'json') {
$formatMime = 'application/json';
} elseif ($this->bodyFormat === 'xml') {
$formatMime = 'application/xml';
}
if (! empty($formatMime) && ! empty($params)) {
$formatted = Services::format()->getFormatter($formatMime)->format($params);
$request->setBody($formatted);
$request->setHeader('Content-Type', $formatMime);
}
}
return $request;
}
}

View File

@@ -0,0 +1,396 @@
<?php
/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace CodeIgniter\Test;
use CodeIgniter\Events\Events;
use CodeIgniter\HTTP\IncomingRequest;
use CodeIgniter\HTTP\Request;
use CodeIgniter\HTTP\URI;
use CodeIgniter\HTTP\UserAgent;
use CodeIgniter\Router\Exceptions\RedirectException;
use CodeIgniter\Router\RouteCollection;
use Config\App;
use Config\Services;
use Exception;
use ReflectionException;
/**
* Trait FeatureTestTrait
*
* Provides additional utilities for doing full HTTP testing
* against your application in trait format.
*/
trait FeatureTestTrait
{
/**
* Sets a RouteCollection that will override
* the application's route collection.
*
* Example routes:
* [
* ['get', 'home', 'Home::index']
* ]
*
* @param array $routes
*
* @return $this
*/
protected function withRoutes(?array $routes = null)
{
$collection = Services::routes();
if ($routes) {
$collection->resetRoutes();
foreach ($routes as $route) {
$collection->{$route[0]}($route[1], $route[2]);
}
}
$this->routes = $collection;
return $this;
}
/**
* Sets any values that should exist during this session.
*
* @param array|null $values Array of values, or null to use the current $_SESSION
*
* @return $this
*/
public function withSession(?array $values = null)
{
$this->session = $values ?? $_SESSION;
return $this;
}
/**
* Set request's headers
*
* Example of use
* withHeaders([
* 'Authorization' => 'Token'
* ])
*
* @param array $headers Array of headers
*
* @return $this
*/
public function withHeaders(array $headers = [])
{
$this->headers = $headers;
return $this;
}
/**
* Set the format the request's body should have.
*
* @param string $format The desired format. Currently supported formats: xml, json
*
* @return $this
*/
public function withBodyFormat(string $format)
{
$this->bodyFormat = $format;
return $this;
}
/**
* Set the raw body for the request
*
* @param mixed $body
*
* @return $this
*/
public function withBody($body)
{
$this->requestBody = $body;
return $this;
}
/**
* Don't run any events while running this test.
*
* @return $this
*/
public function skipEvents()
{
Events::simulate(true);
return $this;
}
/**
* Calls a single URI, executes it, and returns a TestResponse
* instance that can be used to run many assertions against.
*
* @throws RedirectException
* @throws Exception
*
* @return TestResponse
*/
public function call(string $method, string $path, ?array $params = null)
{
$buffer = \ob_get_level();
// Clean up any open output buffers
// not relevant to unit testing
// @codeCoverageIgnoreStart
if (\ob_get_level() > 0 && (! isset($this->clean) || $this->clean === true)) {
\ob_end_clean();
}
// @codeCoverageIgnoreEnd
// Simulate having a blank session
$_SESSION = [];
$_SERVER['REQUEST_METHOD'] = $method;
$request = $this->setupRequest($method, $path);
$request = $this->setupHeaders($request);
$request = $this->populateGlobals($method, $request, $params);
$request = $this->setRequestBody($request);
// Initialize the RouteCollection
if (! $routes = $this->routes) {
require APPPATH . 'Config/Routes.php';
/**
* @var RouteCollection $routes
*/
$routes->getRoutes('*');
}
$routes->setHTTPVerb($method);
// Make sure any other classes that might call the request
// instance get the right one.
Services::injectMock('request', $request);
// Make sure filters are reset between tests
Services::injectMock('filters', Services::filters(null, false));
$response = $this->app
->setRequest($request)
->run($routes, true);
$output = \ob_get_contents();
if (empty($response->getBody()) && ! empty($output)) {
$response->setBody($output);
}
// Reset directory if it has been set
Services::router()->setDirectory(null);
// Ensure the output buffer is identical so no tests are risky
// @codeCoverageIgnoreStart
while (\ob_get_level() > $buffer) {
\ob_end_clean();
}
while (\ob_get_level() < $buffer) {
\ob_start();
}
// @codeCoverageIgnoreEnd
return new TestResponse($response);
}
/**
* Performs a GET request.
*
* @throws RedirectException
* @throws Exception
*
* @return TestResponse
*/
public function get(string $path, ?array $params = null)
{
return $this->call('get', $path, $params);
}
/**
* Performs a POST request.
*
* @throws RedirectException
* @throws Exception
*
* @return TestResponse
*/
public function post(string $path, ?array $params = null)
{
return $this->call('post', $path, $params);
}
/**
* Performs a PUT request
*
* @throws RedirectException
* @throws Exception
*
* @return TestResponse
*/
public function put(string $path, ?array $params = null)
{
return $this->call('put', $path, $params);
}
/**
* Performss a PATCH request
*
* @throws RedirectException
* @throws Exception
*
* @return TestResponse
*/
public function patch(string $path, ?array $params = null)
{
return $this->call('patch', $path, $params);
}
/**
* Performs a DELETE request.
*
* @throws RedirectException
* @throws Exception
*
* @return TestResponse
*/
public function delete(string $path, ?array $params = null)
{
return $this->call('delete', $path, $params);
}
/**
* Performs an OPTIONS request.
*
* @throws RedirectException
* @throws Exception
*
* @return TestResponse
*/
public function options(string $path, ?array $params = null)
{
return $this->call('options', $path, $params);
}
/**
* Setup a Request object to use so that CodeIgniter
* won't try to auto-populate some of the items.
*/
protected function setupRequest(string $method, ?string $path = null): IncomingRequest
{
$path = URI::removeDotSegments($path);
$config = config(App::class);
$request = new IncomingRequest($config, new URI(), null, new UserAgent());
// $path may have a query in it
$parts = explode('?', $path);
$_SERVER['QUERY_STRING'] = $parts[1] ?? '';
$request->setPath($parts[0]);
$request->setMethod($method);
$request->setProtocolVersion('1.1');
if ($config->forceGlobalSecureRequests) {
$_SERVER['HTTPS'] = 'test';
}
return $request;
}
/**
* Setup the custom request's headers
*
* @return IncomingRequest
*/
protected function setupHeaders(IncomingRequest $request)
{
if (! empty($this->headers)) {
foreach ($this->headers as $name => $value) {
$request->setHeader($name, $value);
}
}
return $request;
}
/**
* Populates the data of our Request with "global" data
* relevant to the request, like $_POST data.
*
* Always populate the GET vars based on the URI.
*
* @throws ReflectionException
*
* @return Request
*/
protected function populateGlobals(string $method, Request $request, ?array $params = null)
{
// $params should set the query vars if present,
// otherwise set it from the URL.
$get = ! empty($params) && $method === 'get'
? $params
: $this->getPrivateProperty($request->uri, 'query');
$request->setGlobal('get', $get);
if ($method !== 'get') {
$request->setGlobal($method, $params);
}
$request->setGlobal('request', $params);
$_SESSION = $this->session ?? [];
return $request;
}
/**
* Set the request's body formatted according to the value in $this->bodyFormat.
* This allows the body to be formatted in a way that the controller is going to
* expect as in the case of testing a JSON or XML API.
*
* @param array|null $params The parameters to be formatted and put in the body. If this is empty, it will get the
* what has been loaded into the request global of the request class.
*/
protected function setRequestBody(Request $request, ?array $params = null): Request
{
if (isset($this->requestBody) && $this->requestBody !== '') {
$request->setBody($this->requestBody);
return $request;
}
if (isset($this->bodyFormat) && $this->bodyFormat !== '') {
if (empty($params)) {
$params = $request->fetchGlobal('request');
}
$formatMime = '';
if ($this->bodyFormat === 'json') {
$formatMime = 'application/json';
} elseif ($this->bodyFormat === 'xml') {
$formatMime = 'application/xml';
}
if (! empty($formatMime) && ! empty($params)) {
$formatted = Services::format()->getFormatter($formatMime)->format($params);
$request->setBody($formatted);
$request->setHeader('Content-Type', $formatMime);
}
}
return $request;
}
}

View File

@@ -0,0 +1,270 @@
<?php
/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace CodeIgniter\Test;
use Closure;
use CodeIgniter\Filters\Exceptions\FilterException;
use CodeIgniter\Filters\FilterInterface;
use CodeIgniter\Filters\Filters;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
use CodeIgniter\Router\RouteCollection;
use Config\Filters as FiltersConfig;
use Config\Services;
use InvalidArgumentException;
use RuntimeException;
/**
* Filter Test Trait
*
* Provides functionality for testing
* filters and their route associations.
*
* @mixin CIUnitTestCase
*/
trait FilterTestTrait
{
/**
* Have the one-time classes been instantiated?
*
* @var bool
*/
private $doneFilterSetUp = false;
/**
* The active IncomingRequest or CLIRequest
*
* @var RequestInterface
*/
protected $request;
/**
* The active Response instance
*
* @var ResponseInterface
*/
protected $response;
/**
* The Filters configuration to use.
* Extracted for access to aliases
* during Filters::discoverFilters().
*
* @var FiltersConfig|null
*/
protected $filtersConfig;
/**
* The prepared Filters library.
*
* @var Filters|null
*/
protected $filters;
/**
* The default App and discovered
* routes to check for filters.
*
* @var RouteCollection|null
*/
protected $collection;
//--------------------------------------------------------------------
// Staging
//--------------------------------------------------------------------
/**
* Initializes dependencies once.
*/
protected function setUpFilterTestTrait(): void
{
if ($this->doneFilterSetUp === true) {
return;
}
// Create our own Request and Response so we can
// use the same ones for Filters and FilterInterface
// yet isolate them from outside influence
$this->request = $this->request ?? clone Services::request();
$this->response = $this->response ?? clone Services::response();
// Create our config and Filters instance to reuse for performance
$this->filtersConfig = $this->filtersConfig ?? config('Filters');
$this->filters = $this->filters ?? new Filters($this->filtersConfig, $this->request, $this->response);
if ($this->collection === null) {
// Load the RouteCollection from Config to gather App route info
// (creates $routes using the Service as a starting point)
require APPPATH . 'Config/Routes.php';
$routes->getRoutes('*'); // Triggers discovery
$this->collection = $routes;
}
$this->doneFilterSetUp = true;
}
//--------------------------------------------------------------------
// Utility
//--------------------------------------------------------------------
/**
* Returns a callable method for a filter position
* using the local HTTP instances.
*
* @param FilterInterface|string $filter The filter instance, class, or alias
* @param string $position "before" or "after"
*/
protected function getFilterCaller($filter, string $position): Closure
{
if (! in_array($position, ['before', 'after'], true)) {
throw new InvalidArgumentException('Invalid filter position passed: ' . $position);
}
if (is_string($filter)) {
// Check for an alias (no namespace)
if (strpos($filter, '\\') === false) {
if (! isset($this->filtersConfig->aliases[$filter])) {
throw new RuntimeException("No filter found with alias '{$filter}'");
}
$filter = $this->filtersConfig->aliases[$filter];
}
// Get an instance
$filter = new $filter();
}
if (! $filter instanceof FilterInterface) {
throw FilterException::forIncorrectInterface(get_class($filter));
}
$request = clone $this->request;
if ($position === 'before') {
return static function (?array $params = null) use ($filter, $request) {
return $filter->before($request, $params);
};
}
$response = clone $this->response;
return static function (?array $params = null) use ($filter, $request, $response) {
return $filter->after($request, $response, $params);
};
}
/**
* Gets an array of filter aliases enabled
* for the given route at position.
*
* @param string $route The route to test
* @param string $position "before" or "after"
*
* @return string[] The filter aliases
*/
protected function getFiltersForRoute(string $route, string $position): array
{
if (! in_array($position, ['before', 'after'], true)) {
throw new InvalidArgumentException('Invalid filter position passed:' . $position);
}
$this->filters->reset();
if ($routeFilter = $this->collection->getFilterForRoute($route)) {
$this->filters->enableFilter($routeFilter, $position);
}
$aliases = $this->filters->initialize($route)->getFilters();
$this->filters->reset();
return $aliases[$position];
}
//--------------------------------------------------------------------
// Assertions
//--------------------------------------------------------------------
/**
* Asserts that the given route at position uses
* the filter (by its alias).
*
* @param string $route The route to test
* @param string $position "before" or "after"
* @param string $alias Alias for the anticipated filter
*/
protected function assertFilter(string $route, string $position, string $alias): void
{
$filters = $this->getFiltersForRoute($route, $position);
$this->assertContains(
$alias,
$filters,
"Filter '{$alias}' does not apply to '{$route}'.",
);
}
/**
* Asserts that the given route at position does not
* use the filter (by its alias).
*
* @param string $route The route to test
* @param string $position "before" or "after"
* @param string $alias Alias for the anticipated filter
*/
protected function assertNotFilter(string $route, string $position, string $alias)
{
$filters = $this->getFiltersForRoute($route, $position);
$this->assertNotContains(
$alias,
$filters,
"Filter '{$alias}' applies to '{$route}' when it should not.",
);
}
/**
* Asserts that the given route at position has
* at least one filter set.
*
* @param string $route The route to test
* @param string $position "before" or "after"
*/
protected function assertHasFilters(string $route, string $position)
{
$filters = $this->getFiltersForRoute($route, $position);
$this->assertNotEmpty(
$filters,
"No filters found for '{$route}' when at least one was expected.",
);
}
/**
* Asserts that the given route at position has
* no filters set.
*
* @param string $route The route to test
* @param string $position "before" or "after"
*/
protected function assertNotHasFilters(string $route, string $position)
{
$filters = $this->getFiltersForRoute($route, $position);
$this->assertSame(
[],
$filters,
"Found filters for '{$route}' when none were expected: " . implode(', ', $filters) . '.',
);
}
}

View File

@@ -0,0 +1,50 @@
<?php
/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace CodeIgniter\Test\Filters;
use php_user_filter;
/**
* Used to capture output during unit testing, so that it can
* be used in assertions.
*/
class CITestStreamFilter extends php_user_filter
{
/**
* Buffer to capture stream content.
*
* @var string
*/
public static $buffer = '';
/**
* This method is called whenever data is read from or written to the
* attached stream (such as with fread() or fwrite()).
*
* @param resource $in
* @param resource $out
* @param int $consumed
* @param bool $closing
*/
public function filter($in, $out, &$consumed, $closing): int
{
while ($bucket = stream_bucket_make_writeable($in)) {
static::$buffer .= $bucket->data;
$consumed += $bucket->datalen;
}
return PSFS_PASS_ON;
}
}
stream_filter_register('CITestStreamFilter', CITestStreamFilter::class); // @codeCoverageIgnore

View File

@@ -0,0 +1,84 @@
<?php
/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace CodeIgniter\Test\Interfaces;
use Faker\Generator;
use ReflectionException;
/**
* FabricatorModel
*
* An interface defining the required methods and properties
* needed for a model to qualify for use with the Fabricator class.
* While interfaces cannot enforce properties, the following
* are required for use with Fabricator:
*
* @property string $returnType
* @property string $primaryKey
* @property string $dateFormat
*/
interface FabricatorModel
{
/**
* Fetches the row of database from $this->table with a primary key
* matching $id.
*
* @param array|mixed|null $id One primary key or an array of primary keys
*
* @return array|object|null The resulting row of data, or null.
*/
public function find($id = null);
/**
* Inserts data into the current table. If an object is provided,
* it will attempt to convert it to an array.
*
* @param array|object $data
* @param bool $returnID Whether insert ID should be returned or not.
*
* @throws ReflectionException
*
* @return bool|int|string
*/
public function insert($data = null, bool $returnID = true);
/**
* The following properties and methods are optional, but if present should
* adhere to their definitions.
*
* @property array $allowedFields
* @property string $useSoftDeletes
* @property string $useTimestamps
* @property string $createdField
* @property string $updatedField
* @property string $deletedField
*/
/*
* Sets $useSoftDeletes value so that we can temporarily override
* the softdeletes settings. Can be used for all find* methods.
*
* @param boolean $val
*
* @return Model
*/
// public function withDeleted($val = true);
/**
* Faked data for Fabricator.
*
* @param Generator $faker
*
* @return array|object
*/
// public function fake(Generator &$faker);
}

View File

@@ -0,0 +1,42 @@
<?php
/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace CodeIgniter\Test\Mock;
use Config\App;
class MockAppConfig extends App
{
public $baseURL = 'http://example.com/';
public $uriProtocol = 'REQUEST_URI';
public $cookiePrefix = '';
public $cookieDomain = '';
public $cookiePath = '/';
public $cookieSecure = false;
public $cookieHTTPOnly = false;
public $cookieSameSite = 'Lax';
public $proxyIPs = '';
public $CSRFTokenName = 'csrf_test_name';
public $CSRFHeaderName = 'X-CSRF-TOKEN';
public $CSRFCookieName = 'csrf_cookie_name';
public $CSRFExpire = 7200;
public $CSRFRegenerate = true;
public $CSRFExcludeURIs = ['http://example.com'];
public $CSRFRedirect = false;
public $CSRFSameSite = 'Lax';
public $CSPEnabled = false;
public $defaultLocale = 'en';
public $negotiateLocale = false;
public $supportedLocales = [
'en',
'es',
];
}

View File

@@ -0,0 +1,26 @@
<?php
/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace CodeIgniter\Test\Mock;
use Config\Autoload;
class MockAutoload extends Autoload
{
public $psr4 = [];
public $classmap = [];
public function __construct()
{
// Don't call the parent since we don't want the default mappings.
// parent::__construct();
}
}

View File

@@ -0,0 +1,18 @@
<?php
/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace CodeIgniter\Test\Mock;
use CodeIgniter\Database\BaseBuilder;
class MockBuilder extends BaseBuilder
{
}

View File

@@ -0,0 +1,40 @@
<?php
/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace CodeIgniter\Test\Mock;
use Config\App;
class MockCLIConfig extends App
{
public $baseURL = 'http://example.com/';
public $uriProtocol = 'REQUEST_URI';
public $cookiePrefix = '';
public $cookieDomain = '';
public $cookiePath = '/';
public $cookieSecure = false;
public $cookieHTTPOnly = false;
public $cookieSameSite = 'Lax';
public $proxyIPs = '';
public $CSRFTokenName = 'csrf_test_name';
public $CSRFCookieName = 'csrf_cookie_name';
public $CSRFExpire = 7200;
public $CSRFRegenerate = true;
public $CSRFExcludeURIs = ['http://example.com'];
public $CSRFSameSite = 'Lax';
public $CSPEnabled = false;
public $defaultLocale = 'en';
public $negotiateLocale = false;
public $supportedLocales = [
'en',
'es',
];
}

View File

@@ -0,0 +1,54 @@
<?php
/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace CodeIgniter\Test\Mock;
use CodeIgniter\HTTP\CURLRequest;
/**
* Class MockCURLRequest
*
* Simply allows us to not actually call cURL during the
* test runs. Instead, we can set the desired output
* and get back the set options.
*/
class MockCURLRequest extends CURLRequest
{
public $curl_options;
protected $output = '';
public function setOutput($output)
{
$this->output = $output;
return $this;
}
protected function sendRequest(array $curlOptions = []): string
{
// Save so we can access later.
$this->curl_options = $curlOptions;
return $this->output;
}
// for testing purposes only
public function getBaseURI()
{
return $this->baseURI;
}
// for testing purposes only
public function getDelay()
{
return $this->delay;
}
}

View File

@@ -0,0 +1,297 @@
<?php
/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace CodeIgniter\Test\Mock;
use Closure;
use CodeIgniter\Cache\CacheInterface;
use CodeIgniter\Cache\Handlers\BaseHandler;
use PHPUnit\Framework\Assert;
class MockCache extends BaseHandler implements CacheInterface
{
/**
* Mock cache storage.
*
* @var array
*/
protected $cache = [];
/**
* Expiration times.
*
* @var ?int[]
*/
protected $expirations = [];
/**
* If true, will not cache any data.
*
* @var bool
*/
protected $bypass = false;
/**
* Takes care of any handler-specific setup that must be done.
*/
public function initialize()
{
}
/**
* Attempts to fetch an item from the cache store.
*
* @param string $key Cache item name
*
* @return mixed
*/
public function get(string $key)
{
$key = static::validateKey($key, $this->prefix);
return array_key_exists($key, $this->cache) ? $this->cache[$key] : null;
}
/**
* Get an item from the cache, or execute the given Closure and store the result.
*
* @return mixed
*/
public function remember(string $key, int $ttl, Closure $callback)
{
$value = $this->get($key);
if ($value !== null) {
return $value;
}
$this->save($key, $value = $callback(), $ttl);
return $value;
}
/**
* Saves an item to the cache store.
*
* The $raw parameter is only utilized by Mamcache in order to
* allow usage of increment() and decrement().
*
* @param string $key Cache item name
* @param mixed $value the data to save
* @param int $ttl Time To Live, in seconds (default 60)
* @param bool $raw Whether to store the raw value.
*
* @return bool
*/
public function save(string $key, $value, int $ttl = 60, bool $raw = false)
{
if ($this->bypass) {
return false;
}
$key = static::validateKey($key, $this->prefix);
$this->cache[$key] = $value;
$this->expirations[$key] = $ttl > 0 ? time() + $ttl : null;
return true;
}
/**
* Deletes a specific item from the cache store.
*
* @return bool
*/
public function delete(string $key)
{
$key = static::validateKey($key, $this->prefix);
if (! isset($this->cache[$key])) {
return false;
}
unset($this->cache[$key], $this->expirations[$key]);
return true;
}
/**
* Deletes items from the cache store matching a given pattern.
*
* @return int
*/
public function deleteMatching(string $pattern)
{
$count = 0;
foreach (array_keys($this->cache) as $key) {
if (fnmatch($pattern, $key)) {
$count++;
unset($this->cache[$key], $this->expirations[$key]);
}
}
return $count;
}
/**
* Performs atomic incrementation of a raw stored value.
*
* @return bool
*/
public function increment(string $key, int $offset = 1)
{
$key = static::validateKey($key, $this->prefix);
$data = $this->cache[$key] ?: null;
if (empty($data)) {
$data = 0;
} elseif (! is_int($data)) {
return false;
}
return $this->save($key, $data + $offset);
}
/**
* Performs atomic decrementation of a raw stored value.
*
* @return bool
*/
public function decrement(string $key, int $offset = 1)
{
$key = static::validateKey($key, $this->prefix);
$data = $this->cache[$key] ?: null;
if (empty($data)) {
$data = 0;
} elseif (! is_int($data)) {
return false;
}
return $this->save($key, $data - $offset);
}
/**
* Will delete all items in the entire cache.
*
* @return bool
*/
public function clean()
{
$this->cache = [];
$this->expirations = [];
return true;
}
/**
* Returns information on the entire cache.
*
* The information returned and the structure of the data
* varies depending on the handler.
*
* @return string[] Keys currently present in the store
*/
public function getCacheInfo()
{
return array_keys($this->cache);
}
/**
* Returns detailed information about the specific item in the cache.
*
* @return array|null Returns null if the item does not exist, otherwise array<string, mixed>
* with at least the 'expire' key for absolute epoch expiry (or null).
*/
public function getMetaData(string $key)
{
// Misses return null
if (! array_key_exists($key, $this->expirations)) {
return null;
}
// Count expired items as a miss
if (is_int($this->expirations[$key]) && $this->expirations[$key] > time()) {
return null;
}
return ['expire' => $this->expirations[$key]];
}
/**
* Determine if the driver is supported on this system.
*/
public function isSupported(): bool
{
return true;
}
//--------------------------------------------------------------------
// Test Helpers
//--------------------------------------------------------------------
/**
* Instructs the class to ignore all
* requests to cache an item, and always "miss"
* when checked for existing data.
*
* @return $this
*/
public function bypass(bool $bypass = true)
{
$this->clean();
$this->bypass = $bypass;
return $this;
}
//--------------------------------------------------------------------
// Additional Assertions
//--------------------------------------------------------------------
/**
* Asserts that the cache has an item named $key.
* The value is not checked since storing false or null
* values is valid.
*/
public function assertHas(string $key)
{
Assert::assertNotNull($this->get($key), "The cache does not have an item named: `{$key}`");
}
/**
* Asserts that the cache has an item named $key with a value matching $value.
*
* @param mixed $value
*/
public function assertHasValue(string $key, $value = null)
{
$item = $this->get($key);
// Let assertHas handle throwing the error for consistency
// if the key is not found
if (empty($item)) {
$this->assertHas($key);
}
Assert::assertSame($value, $this->get($key), "The cached item `{$key}` does not equal match expectation. Found: " . print_r($value, true));
}
/**
* Asserts that the cache does NOT have an item named $key.
*/
public function assertMissing(string $key)
{
Assert::assertArrayNotHasKey($key, $this->cache, "The cached item named `{$key}` exists.");
}
}

View File

@@ -0,0 +1,22 @@
<?php
/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace CodeIgniter\Test\Mock;
use CodeIgniter\CodeIgniter;
class MockCodeIgniter extends CodeIgniter
{
protected function callExit($code)
{
// Do not call exit() in testing.
}
}

View File

@@ -0,0 +1,32 @@
<?php
/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
if (! function_exists('is_cli')) {
/**
* Is CLI?
*
* Test to see if a request was made from the command line.
* You can set the return value for testing.
*
* @param bool $newReturn return value to set
*/
function is_cli(?bool $newReturn = null): bool
{
// PHPUnit always runs via CLI.
static $returnValue = true;
if ($newReturn !== null) {
$returnValue = $newReturn;
}
return $returnValue;
}
}

View File

@@ -0,0 +1,233 @@
<?php
/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace CodeIgniter\Test\Mock;
use CodeIgniter\CodeIgniter;
use CodeIgniter\Database\BaseConnection;
use CodeIgniter\Database\BaseResult;
use CodeIgniter\Database\Query;
class MockConnection extends BaseConnection
{
protected $returnValues = [];
public $database;
public $lastQuery;
public function shouldReturn(string $method, $return)
{
$this->returnValues[$method] = $return;
return $this;
}
/**
* Orchestrates a query against the database. Queries must use
* Database\Statement objects to store the query and build it.
* This method works with the cache.
*
* Should automatically handle different connections for read/write
* queries if needed.
*
* @param mixed ...$binds
*
* @return BaseResult|bool|Query
*
* @todo BC set $queryClass default as null in 4.1
*/
public function query(string $sql, $binds = null, bool $setEscapeFlags = true, string $queryClass = '')
{
$queryClass = str_replace('Connection', 'Query', static::class);
$query = new $queryClass($this);
$query->setQuery($sql, $binds, $setEscapeFlags);
if (! empty($this->swapPre) && ! empty($this->DBPrefix)) {
$query->swapPrefix($this->DBPrefix, $this->swapPre);
}
$startTime = microtime(true);
$this->lastQuery = $query;
// Run the query
if (false === ($this->resultID = $this->simpleQuery($query->getQuery()))) {
$query->setDuration($startTime, $startTime);
// @todo deal with errors
return false;
}
$query->setDuration($startTime);
// resultID is not false, so it must be successful
if ($query->isWriteType()) {
return true;
}
// query is not write-type, so it must be read-type query; return QueryResult
$resultClass = str_replace('Connection', 'Result', static::class);
return new $resultClass($this->connID, $this->resultID);
}
/**
* Connect to the database.
*
* @return mixed
*/
public function connect(bool $persistent = false)
{
$return = $this->returnValues['connect'] ?? true;
if (is_array($return)) {
// By removing the top item here, we can
// get a different value for, say, testing failover connections.
$return = array_shift($this->returnValues['connect']);
}
return $return;
}
/**
* Keep or establish the connection if no queries have been sent for
* a length of time exceeding the server's idle timeout.
*/
public function reconnect(): bool
{
return true;
}
/**
* Select a specific database table to use.
*
* @return mixed
*/
public function setDatabase(string $databaseName)
{
$this->database = $databaseName;
return $this;
}
/**
* Returns a string containing the version of the database being used.
*/
public function getVersion(): string
{
return CodeIgniter::CI_VERSION;
}
/**
* Executes the query against the database.
*
* @return mixed
*/
protected function execute(string $sql)
{
return $this->returnValues['execute'];
}
/**
* Returns the total number of rows affected by this query.
*/
public function affectedRows(): int
{
return 1;
}
/**
* Returns the last error code and message.
*
* Must return an array with keys 'code' and 'message':
*
* return ['code' => null, 'message' => null);
*/
public function error(): array
{
return [
'code' => 0,
'message' => '',
];
}
/**
* Insert ID
*/
public function insertID(): int
{
return $this->connID->insert_id;
}
/**
* Generates the SQL for listing tables in a platform-dependent manner.
*/
protected function _listTables(bool $constrainByPrefix = false): string
{
return '';
}
/**
* Generates a platform-specific query string so that the column names can be fetched.
*/
protected function _listColumns(string $table = ''): string
{
return '';
}
protected function _fieldData(string $table): array
{
return [];
}
protected function _indexData(string $table): array
{
return [];
}
protected function _foreignKeyData(string $table): array
{
return [];
}
/**
* Close the connection.
*/
protected function _close()
{
}
/**
* Begin Transaction
*/
protected function _transBegin(): bool
{
return true;
}
/**
* Commit Transaction
*/
protected function _transCommit(): bool
{
return true;
}
/**
* Rollback Transaction
*/
protected function _transRollback(): bool
{
return true;
}
}

View File

@@ -0,0 +1,40 @@
<?php
/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace CodeIgniter\Test\Mock;
use CodeIgniter\Email\Email;
use CodeIgniter\Events\Events;
class MockEmail extends Email
{
/**
* Value to return from mocked send().
*
* @var bool
*/
public $returnValue = true;
public function send($autoClear = true)
{
if ($this->returnValue) {
$this->setArchiveValues();
if ($autoClear) {
$this->clear();
}
Events::trigger('email', $this->archive);
}
return $this->returnValue;
}
}

View File

@@ -0,0 +1,40 @@
<?php
/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace CodeIgniter\Test\Mock;
use CodeIgniter\Events\Events;
/**
* Events
*/
class MockEvents extends Events
{
public function getListeners()
{
return self::$listeners;
}
public function getEventsFile()
{
return self::$files;
}
public function getSimulate()
{
return self::$simulate;
}
public function unInitialize()
{
static::$initialized = false;
}
}

View File

@@ -0,0 +1,34 @@
<?php
/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace CodeIgniter\Test\Mock;
use CodeIgniter\Log\Handlers\FileHandler;
/**
* Class MockFileLogger
*
* Extends FileHandler, exposing some inner workings
*/
class MockFileLogger extends FileHandler
{
/**
* Where would the log be written?
*/
public $destination;
public function __construct(array $config)
{
parent::__construct($config);
$this->handles = $config['handles'] ?? [];
$this->destination = $this->path . 'log-' . date('Y-m-d') . '.' . $this->fileExtension;
}
}

View File

@@ -0,0 +1,22 @@
<?php
/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace CodeIgniter\Test\Mock;
use CodeIgniter\HTTP\IncomingRequest;
class MockIncomingRequest extends IncomingRequest
{
protected function detectURI($protocol, $baseURL)
{
// Do nothing...
}
}

View File

@@ -0,0 +1,56 @@
<?php
/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace CodeIgniter\Test\Mock;
use CodeIgniter\Language\Language;
class MockLanguage extends Language
{
/**
* Stores the data that should be
* returned by the 'requireFile()' method.
*
* @var mixed
*/
protected $data;
/**
* Sets the data that should be returned by the
* 'requireFile()' method to allow easy overrides
* during testing.
*
* @return $this
*/
public function setData(string $file, array $data, ?string $locale = null)
{
$this->language[$locale ?? $this->locale][$file] = $data;
return $this;
}
/**
* Provides an override that allows us to set custom
* data to be returned easily during testing.
*/
protected function requireFile(string $path): array
{
return $this->data ?? [];
}
/**
* Arbitrarily turnoff internationalization support for testing
*/
public function disableIntlSupport()
{
$this->intlSupport = false;
}
}

View File

@@ -0,0 +1,101 @@
<?php
/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace CodeIgniter\Test\Mock;
class MockLogger
{
/*
|--------------------------------------------------------------------------
| Error Logging Threshold
|--------------------------------------------------------------------------
|
| You can enable error logging by setting a threshold over zero. The
| threshold determines what gets logged. Any values below or equal to the
| threshold will be logged. Threshold options are:
|
| 0 = Disables logging, Error logging TURNED OFF
| 1 = Emergency Messages - System is unusable
| 2 = Alert Messages - Action Must Be Taken Immediately
| 3 = Critical Messages - Application component unavailable, unexpected exception.
| 4 = Runtime Errors - Don't need immediate action, but should be monitored.
| 5 = Warnings - Exceptional occurrences that are not errors.
| 6 = Notices - Normal but significant events.
| 7 = Info - Interesting events, like user logging in, etc.
| 8 = Debug - Detailed debug information.
| 9 = All Messages
|
| You can also pass an array with threshold levels to show individual error types
|
| array(1, 2, 3, 8) = Emergency, Alert, Critical, and Debug messages
|
| For a live site you'll usually enable Critical or higher (3) to be logged otherwise
| your log files will fill up very fast.
|
*/
public $threshold = 9;
/*
|--------------------------------------------------------------------------
| Date Format for Logs
|--------------------------------------------------------------------------
|
| Each item that is logged has an associated date. You can use PHP date
| codes to set your own date formatting
|
*/
public $dateFormat = 'Y-m-d';
/*
|--------------------------------------------------------------------------
| Log Handlers
|--------------------------------------------------------------------------
|
| The logging system supports multiple actions to be taken when something
| is logged. This is done by allowing for multiple Handlers, special classes
| designed to write the log to their chosen destinations, whether that is
| a file on the getServer, a cloud-based service, or even taking actions such
| as emailing the dev team.
|
| Each handler is defined by the class name used for that handler, and it
| MUST implement the CodeIgniter\Log\Handlers\HandlerInterface interface.
|
| The value of each key is an array of configuration items that are sent
| to the constructor of each handler. The only required configuration item
| is the 'handles' element, which must be an array of integer log levels.
| This is most easily handled by using the constants defined in the
| Psr\Log\LogLevel class.
|
| Handlers are executed in the order defined in this array, starting with
| the handler on top and continuing down.
|
*/
public $handlers = [
// File Handler
'Tests\Support\Log\Handlers\TestHandler' => [
// The log levels that this handler will handle.
'handles' => [
'critical',
'alert',
'emergency',
'debug',
'error',
'info',
'notice',
'warning',
],
// Logging Directory Path
'path' => '',
],
];
}

View File

@@ -0,0 +1,18 @@
<?php
/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace CodeIgniter\Test\Mock;
use CodeIgniter\Database\Query;
class MockQuery extends Query
{
}

View File

@@ -0,0 +1,32 @@
<?php
/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace CodeIgniter\Test\Mock;
use CodeIgniter\RESTful\ResourceController;
class MockResourceController extends ResourceController
{
public function getModel()
{
return $this->model;
}
public function getModelName()
{
return $this->modelName;
}
public function getFormat()
{
return $this->format;
}
}

View File

@@ -0,0 +1,35 @@
<?php
/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace CodeIgniter\Test\Mock;
use CodeIgniter\API\ResponseTrait;
use CodeIgniter\RESTful\ResourcePresenter;
class MockResourcePresenter extends ResourcePresenter
{
use ResponseTrait;
public function getModel()
{
return $this->model;
}
public function getModelName()
{
return $this->modelName;
}
public function getFormat()
{
return $this->format;
}
}

View File

@@ -0,0 +1,39 @@
<?php
/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace CodeIgniter\Test\Mock;
use CodeIgniter\HTTP\Response;
/**
* Class MockResponse
*/
class MockResponse extends Response
{
/**
* If true, will not write output. Useful during testing.
*
* @var bool
*/
protected $pretend = true;
// for testing
public function getPretend()
{
return $this->pretend;
}
// artificial error for testing
public function misbehave()
{
$this->statusCode = 0;
}
}

View File

@@ -0,0 +1,96 @@
<?php
/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace CodeIgniter\Test\Mock;
use CodeIgniter\Database\BaseResult;
class MockResult extends BaseResult
{
/**
* Gets the number of fields in the result set.
*/
public function getFieldCount(): int
{
return 0;
}
/**
* Generates an array of column names in the result set.
*/
public function getFieldNames(): array
{
return [];
}
/**
* Generates an array of objects representing field meta-data.
*/
public function getFieldData(): array
{
return [];
}
/**
* Frees the current result.
*
* @return mixed
*/
public function freeResult()
{
}
/**
* Moves the internal pointer to the desired offset. This is called
* internally before fetching results to make sure the result set
* starts at zero.
*
* @param int $n
*
* @return mixed
*/
public function dataSeek($n = 0)
{
}
/**
* Returns the result set as an array.
*
* Overridden by driver classes.
*
* @return mixed
*/
protected function fetchAssoc()
{
}
/**
* Returns the result set as an object.
*
* Overridden by child classes.
*
* @param string $className
*
* @return object
*/
protected function fetchObject($className = 'stdClass')
{
return new $className();
}
/**
* Gets the number of fields in the result set.
*/
public function getNumRows(): int
{
return 0;
}
}

View File

@@ -0,0 +1,30 @@
<?php
/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace CodeIgniter\Test\Mock;
use CodeIgniter\Security\Security;
class MockSecurity extends Security
{
protected function doSendCookie(): void
{
$_COOKIE['csrf_cookie_name'] = $this->hash;
}
protected function randomize(string $hash): string
{
$keyBinary = hex2bin('005513c290126d34d41bf41c5265e0f1');
$hashBinary = hex2bin($hash);
return bin2hex(($hashBinary ^ $keyBinary) . $keyBinary);
}
}

View File

@@ -0,0 +1,31 @@
<?php
/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace CodeIgniter\Test\Mock;
use Config\Security as Security;
/**
* @deprecated
*
* @codeCoverageIgnore
*/
class MockSecurityConfig extends Security
{
public $tokenName = 'csrf_test_name';
public $headerName = 'X-CSRF-TOKEN';
public $cookieName = 'csrf_cookie_name';
public $expires = 7200;
public $regenerate = true;
public $redirect = false;
public $samesite = 'Lax';
public $excludeURIs = ['http://example.com'];
}

View File

@@ -0,0 +1,34 @@
<?php
/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace CodeIgniter\Test\Mock;
use CodeIgniter\Autoloader\FileLocator;
use CodeIgniter\Config\BaseService;
class MockServices extends BaseService
{
public $psr4 = [
'Tests/Support' => TESTPATH . '_support/',
];
public $classmap = [];
public function __construct()
{
// Don't call the parent since we don't want the default mappings.
// parent::__construct();
}
public static function locator(bool $getShared = true)
{
return new FileLocator(static::autoloader());
}
}

View File

@@ -0,0 +1,70 @@
<?php
/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace CodeIgniter\Test\Mock;
use CodeIgniter\Cookie\Cookie;
use CodeIgniter\Session\Session;
/**
* Class MockSession
*
* Provides a safe way to test the Session class itself,
* that doesn't interact with the session or cookies at all.
*/
class MockSession extends Session
{
/**
* Holds our "cookie" data.
*
* @var Cookie[]
*/
public $cookies = [];
public $didRegenerate = false;
/**
* Sets the driver as the session handler in PHP.
* Extracted for easier testing.
*/
protected function setSaveHandler()
{
// session_set_save_handler($this->driver, true);
}
/**
* Starts the session.
* Extracted for testing reasons.
*/
protected function startSession()
{
// session_start();
$this->setCookie();
}
/**
* Takes care of setting the cookie on the client side.
* Extracted for testing reasons.
*/
protected function setCookie()
{
$expiration = $this->sessionExpiration === 0 ? 0 : time() + $this->sessionExpiration;
$this->cookie = $this->cookie->withValue(session_id())->withExpires($expiration);
$this->cookies[] = $this->cookie;
}
public function regenerate(bool $destroy = false)
{
$this->didRegenerate = true;
$_SESSION['__ci_last_regenerate'] = time();
}
}

View File

@@ -0,0 +1,28 @@
<?php
/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace CodeIgniter\Test\Mock;
use BadMethodCallException;
use CodeIgniter\View\Table;
class MockTable extends Table
{
// Override inaccessible protected method
public function __call($method, $params)
{
if (is_callable([$this, '_' . $method])) {
return call_user_func_array([$this, '_' . $method], $params);
}
throw new BadMethodCallException('Method ' . $method . ' was not found');
}
}

View File

@@ -0,0 +1,98 @@
<?php
/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace CodeIgniter\Test;
use Closure;
use ReflectionClass;
use ReflectionException;
use ReflectionMethod;
use ReflectionObject;
use ReflectionProperty;
/**
* Testing helper.
*/
trait ReflectionHelper
{
/**
* Find a private method invoker.
*
* @param object|string $obj object or class name
* @param string $method method name
*
* @throws ReflectionException
*
* @return Closure
*/
public static function getPrivateMethodInvoker($obj, $method)
{
$refMethod = new ReflectionMethod($obj, $method);
$refMethod->setAccessible(true);
$obj = (gettype($obj) === 'object') ? $obj : null;
return static function (...$args) use ($obj, $refMethod) {
return $refMethod->invokeArgs($obj, $args);
};
}
/**
* Find an accessible property.
*
* @param object|string $obj
* @param string $property
*
* @throws ReflectionException
*
* @return ReflectionProperty
*/
private static function getAccessibleRefProperty($obj, $property)
{
$refClass = is_object($obj) ? new ReflectionObject($obj) : new ReflectionClass($obj);
$refProperty = $refClass->getProperty($property);
$refProperty->setAccessible(true);
return $refProperty;
}
/**
* Set a private property.
*
* @param object|string $obj object or class name
* @param string $property property name
* @param mixed $value value
*
* @throws ReflectionException
*/
public static function setPrivateProperty($obj, $property, $value)
{
$refProperty = self::getAccessibleRefProperty($obj, $property);
$refProperty->setValue($obj, $value);
}
/**
* Retrieve a private property.
*
* @param object|string $obj object or class name
* @param string $property property name
*
* @throws ReflectionException
*
* @return mixed value
*/
public static function getPrivateProperty($obj, $property)
{
$refProperty = self::getAccessibleRefProperty($obj, $property);
return is_string($obj) ? $refProperty->getValue() : $refProperty->getValue($obj);
}
}

View File

@@ -0,0 +1,78 @@
<?php
/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace CodeIgniter\Test;
use CodeIgniter\Log\Logger;
class TestLogger extends Logger
{
protected static $op_logs = [];
/**
* The log method is overridden so that we can store log history during
* the tests to allow us to check ->assertLogged() methods.
*
* @param string $level
* @param string $message
*/
public function log($level, $message, array $context = []): bool
{
// While this requires duplicate work, we want to ensure
// we have the final message to test against.
$logMessage = $this->interpolate($message, $context);
// Determine the file and line by finding the first
// backtrace that is not part of our logging system.
$trace = debug_backtrace();
$file = null;
foreach ($trace as $row) {
if (! in_array($row['function'], ['log', 'log_message'], true)) {
$file = basename($row['file'] ?? '');
break;
}
}
self::$op_logs[] = [
'level' => $level,
'message' => $logMessage,
'file' => $file,
];
// Let the parent do it's thing.
return parent::log($level, $message, $context);
}
/**
* Used by CIUnitTestCase class to provide ->assertLogged() methods.
*
* @param string $message
*
* @return bool
*/
public static function didLog(string $level, $message)
{
foreach (self::$op_logs as $log) {
if (strtolower($log['level']) === strtolower($level) && $message === $log['message']) {
return true;
}
}
return false;
}
// Expose cleanFileNames()
public function cleanup($file)
{
return $this->cleanFileNames($file);
}
}

View File

@@ -0,0 +1,496 @@
<?php
/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace CodeIgniter\Test;
use CodeIgniter\HTTP\RedirectResponse;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
use Config\Services;
use Exception;
use PHPUnit\Framework\Constraint\IsEqual;
use PHPUnit\Framework\TestCase;
/**
* Test Response Class
*
* Consolidated response processing
* for test results.
*
* @no-final
*
* @internal
*/
class TestResponse extends TestCase
{
/**
* The request.
*
* @var RequestInterface|null
*/
protected $request;
/**
* The response.
*
* @var ResponseInterface
*/
protected $response;
/**
* DOM for the body.
*
* @var DOMParser
*/
protected $domParser;
/**
* Stores or the Response and parses the body in the DOM.
*/
public function __construct(ResponseInterface $response)
{
$this->setResponse($response);
}
//--------------------------------------------------------------------
// Getters / Setters
//--------------------------------------------------------------------
/**
* Sets the request.
*
* @return $this
*/
public function setRequest(RequestInterface $request)
{
$this->request = $request;
return $this;
}
/**
* Sets the Response and updates the DOM.
*
* @return $this
*/
public function setResponse(ResponseInterface $response)
{
$this->response = $response;
$this->domParser = new DOMParser();
$body = $response->getBody();
if (is_string($body) && $body !== '') {
$this->domParser->withString($body);
}
return $this;
}
/**
* Request accessor.
*
* @return RequestInterface|null
*/
public function request()
{
return $this->request;
}
/**
* Response accessor.
*
* @return ResponseInterface
*/
public function response()
{
return $this->response;
}
//--------------------------------------------------------------------
// Status Checks
//--------------------------------------------------------------------
/**
* Boils down the possible responses into a boolean valid/not-valid
* response type.
*/
public function isOK(): bool
{
$status = $this->response->getStatusCode();
// Only 200 and 300 range status codes
// are considered valid.
if ($status >= 400 || $status < 200) {
return false;
}
// Empty bodies are not considered valid, unless in redirects
return ! ($status < 300 && empty($this->response->getBody()));
}
/**
* Asserts that the status is a specific value.
*
* @throws Exception
*/
public function assertStatus(int $code)
{
$this->assertSame($code, $this->response->getStatusCode());
}
/**
* Asserts that the Response is considered OK.
*
* @throws Exception
*/
public function assertOK()
{
$this->assertTrue($this->isOK(), "{$this->response->getStatusCode()} is not a successful status code, or the Response has an empty body.");
}
/**
* Asserts that the Response is considered OK.
*
* @throws Exception
*/
public function assertNotOK()
{
$this->assertFalse($this->isOK(), "{$this->response->getStatusCode()} is an unexpected successful status code, or the Response has body content.");
}
//--------------------------------------------------------------------
// Redirection
//--------------------------------------------------------------------
/**
* Returns whether or not the Response was a redirect or RedirectResponse
*/
public function isRedirect(): bool
{
return $this->response instanceof RedirectResponse
|| $this->response->hasHeader('Location')
|| $this->response->hasHeader('Refresh');
}
/**
* Assert that the given response was a redirect.
*
* @throws Exception
*/
public function assertRedirect()
{
$this->assertTrue($this->isRedirect(), 'Response is not a redirect or RedirectResponse.');
}
/**
* Assert that a given response was a redirect
* and it was redirect to a specific URI.
*
* @throws Exception
*/
public function assertRedirectTo(string $uri)
{
$this->assertRedirect();
$uri = trim(strtolower($uri));
$redirectUri = strtolower($this->getRedirectUrl());
$matches = $uri === $redirectUri
|| strtolower(site_url($uri)) === $redirectUri
|| $uri === site_url($redirectUri);
$this->assertTrue($matches, "Redirect URL `{$uri}` does not match `{$redirectUri}`");
}
/**
* Assert that the given response was not a redirect.
*
* @throws Exception
*/
public function assertNotRedirect()
{
$this->assertFalse($this->isRedirect(), 'Response is an unexpected redirect or RedirectResponse.');
}
/**
* Returns the URL set for redirection.
*/
public function getRedirectUrl(): ?string
{
if (! $this->isRedirect()) {
return null;
}
if ($this->response->hasHeader('Location')) {
return $this->response->getHeaderLine('Location');
}
if ($this->response->hasHeader('Refresh')) {
return str_replace('0;url=', '', $this->response->getHeaderLine('Refresh'));
}
return null;
}
//--------------------------------------------------------------------
// Session
//--------------------------------------------------------------------
/**
* Asserts that an SESSION key has been set and, optionally, test it's value.
*
* @param mixed $value
*
* @throws Exception
*/
public function assertSessionHas(string $key, $value = null)
{
$this->assertArrayHasKey($key, $_SESSION, "'{$key}' is not in the current \$_SESSION");
if ($value === null) {
return;
}
if (is_scalar($value)) {
$this->assertSame($value, $_SESSION[$key], "The value of '{$key}' ({$value}) does not match expected value.");
} else {
$this->assertSame($value, $_SESSION[$key], "The value of '{$key}' does not match expected value.");
}
}
/**
* Asserts the session is missing $key.
*
* @throws Exception
*/
public function assertSessionMissing(string $key)
{
$this->assertArrayNotHasKey($key, $_SESSION, "'{$key}' should not be present in \$_SESSION.");
}
//--------------------------------------------------------------------
// Headers
//--------------------------------------------------------------------
/**
* Asserts that the Response contains a specific header.
*
* @param string|null $value
*
* @throws Exception
*/
public function assertHeader(string $key, $value = null)
{
$this->assertTrue($this->response->hasHeader($key), "'{$key}' is not a valid Response header.");
if ($value !== null) {
$this->assertSame($value, $this->response->getHeaderLine($key), "The value of '{$key}' header ({$this->response->getHeaderLine($key)}) does not match expected value.");
}
}
/**
* Asserts the Response headers does not contain the specified header.
*
* @throws Exception
*/
public function assertHeaderMissing(string $key)
{
$this->assertFalse($this->response->hasHeader($key), "'{$key}' should not be in the Response headers.");
}
//--------------------------------------------------------------------
// Cookies
//--------------------------------------------------------------------
/**
* Asserts that the response has the specified cookie.
*
* @param string|null $value
*
* @throws Exception
*/
public function assertCookie(string $key, $value = null, string $prefix = '')
{
$this->assertTrue($this->response->hasCookie($key, $value, $prefix), "No cookie found named '{$key}'.");
}
/**
* Assert the Response does not have the specified cookie set.
*/
public function assertCookieMissing(string $key)
{
$this->assertFalse($this->response->hasCookie($key), "Cookie named '{$key}' should not be set.");
}
/**
* Asserts that a cookie exists and has an expired time.
*
* @throws Exception
*/
public function assertCookieExpired(string $key, string $prefix = '')
{
$this->assertTrue($this->response->hasCookie($key, null, $prefix));
$this->assertGreaterThan(time(), $this->response->getCookie($key, $prefix)->getExpiresTimestamp());
}
//--------------------------------------------------------------------
// JSON
//--------------------------------------------------------------------
/**
* Returns the response's body as JSON
*
* @return false|mixed
*/
public function getJSON()
{
$response = $this->response->getJSON();
if ($response === null) {
return false;
}
return $response;
}
/**
* Test that the response contains a matching JSON fragment.
*
* @throws Exception
*/
public function assertJSONFragment(array $fragment, bool $strict = false)
{
$json = json_decode($this->getJSON(), true);
$this->assertIsArray($json, 'Response does not have valid json');
$patched = array_replace_recursive($json, $fragment);
if ($strict) {
$this->assertSame($json, $patched, 'Response does not contain a matching JSON fragment.');
} else {
$this->assertThat($patched, new IsEqual($json), 'Response does not contain a matching JSON fragment.');
}
}
/**
* Asserts that the JSON exactly matches the passed in data.
* If the value being passed in is a string, it must be a json_encoded string.
*
* @param array|string $test
*
* @throws Exception
*/
public function assertJSONExact($test)
{
$json = $this->getJSON();
if (is_object($test)) {
$test = method_exists($test, 'toArray') ? $test->toArray() : (array) $test;
}
if (is_array($test)) {
$test = Services::format()->getFormatter('application/json')->format($test);
}
$this->assertJsonStringEqualsJsonString($test, $json, 'Response does not contain matching JSON.');
}
//--------------------------------------------------------------------
// XML Methods
//--------------------------------------------------------------------
/**
* Returns the response' body as XML
*
* @return mixed|string
*/
public function getXML()
{
return $this->response->getXML();
}
//--------------------------------------------------------------------
// DomParser
//--------------------------------------------------------------------
/**
* Assert that the desired text can be found in the result body.
*
* @throws Exception
*/
public function assertSee(?string $search = null, ?string $element = null)
{
$this->assertTrue($this->domParser->see($search, $element), "Do not see '{$search}' in response.");
}
/**
* Asserts that we do not see the specified text.
*
* @throws Exception
*/
public function assertDontSee(?string $search = null, ?string $element = null)
{
$this->assertTrue($this->domParser->dontSee($search, $element), "I should not see '{$search}' in response.");
}
/**
* Assert that we see an element selected via a CSS selector.
*
* @throws Exception
*/
public function assertSeeElement(string $search)
{
$this->assertTrue($this->domParser->seeElement($search), "Do not see element with selector '{$search} in response.'");
}
/**
* Assert that we do not see an element selected via a CSS selector.
*
* @throws Exception
*/
public function assertDontSeeElement(string $search)
{
$this->assertTrue($this->domParser->dontSeeElement($search), "I should not see an element with selector '{$search}' in response.'");
}
/**
* Assert that we see a link with the matching text and/or class.
*
* @throws Exception
*/
public function assertSeeLink(string $text, ?string $details = null)
{
$this->assertTrue($this->domParser->seeLink($text, $details), "Do no see anchor tag with the text {$text} in response.");
}
/**
* Assert that we see an input with name/value.
*
* @throws Exception
*/
public function assertSeeInField(string $field, ?string $value = null)
{
$this->assertTrue($this->domParser->seeInField($field, $value), "Do no see input named {$field} with value {$value} in response.");
}
/**
* Forward any unrecognized method calls to our DOMParser instance.
*
* @param string $function Method name
* @param mixed $params Any method parameters
*
* @return mixed
*/
public function __call($function, $params)
{
if (method_exists($this->domParser, $function)) {
return $this->domParser->{$function}(...$params);
}
}
}

106
system/Test/bootstrap.php Normal file
View File

@@ -0,0 +1,106 @@
<?php
/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
use CodeIgniter\Config\DotEnv;
use CodeIgniter\Router\RouteCollection;
use CodeIgniter\Services;
use Config\Autoload;
use Config\Modules;
use Config\Paths;
error_reporting(E_ALL);
ini_set('display_errors', '1');
ini_set('display_startup_errors', '1');
// Make sure it recognizes that we're testing.
$_SERVER['CI_ENVIRONMENT'] = 'testing';
define('ENVIRONMENT', 'testing');
defined('CI_DEBUG') || define('CI_DEBUG', true);
// Often these constants are pre-defined, but query the current directory structure as a fallback
defined('HOMEPATH') || define('HOMEPATH', realpath(rtrim(getcwd(), '\\/ ')) . DIRECTORY_SEPARATOR);
$source = is_dir(HOMEPATH . 'app')
? HOMEPATH
: (is_dir('vendor/codeigniter4/framework/')
? 'vendor/codeigniter4/framework/'
: 'vendor/codeigniter4/codeigniter4/');
defined('CONFIGPATH') || define('CONFIGPATH', realpath($source . 'app/Config') . DIRECTORY_SEPARATOR);
defined('PUBLICPATH') || define('PUBLICPATH', realpath($source . 'public') . DIRECTORY_SEPARATOR);
unset($source);
// Load framework paths from their config file
require CONFIGPATH . 'Paths.php';
$paths = new Paths();
// Define necessary framework path constants
defined('APPPATH') || define('APPPATH', realpath(rtrim($paths->appDirectory, '\\/ ')) . DIRECTORY_SEPARATOR);
defined('WRITEPATH') || define('WRITEPATH', realpath(rtrim($paths->writableDirectory, '\\/ ')) . DIRECTORY_SEPARATOR);
defined('SYSTEMPATH') || define('SYSTEMPATH', realpath(rtrim($paths->systemDirectory, '\\/')) . DIRECTORY_SEPARATOR);
defined('ROOTPATH') || define('ROOTPATH', realpath(APPPATH . '../') . DIRECTORY_SEPARATOR);
defined('CIPATH') || define('CIPATH', realpath(SYSTEMPATH . '../') . DIRECTORY_SEPARATOR);
defined('FCPATH') || define('FCPATH', realpath(PUBLICPATH) . DIRECTORY_SEPARATOR);
defined('TESTPATH') || define('TESTPATH', realpath(HOMEPATH . 'tests/') . DIRECTORY_SEPARATOR);
defined('SUPPORTPATH') || define('SUPPORTPATH', realpath(TESTPATH . '_support/') . DIRECTORY_SEPARATOR);
defined('COMPOSER_PATH') || define('COMPOSER_PATH', realpath(HOMEPATH . 'vendor/autoload.php'));
defined('VENDORPATH') || define('VENDORPATH', realpath(HOMEPATH . 'vendor') . DIRECTORY_SEPARATOR);
// Load Common.php from App then System
if (file_exists(APPPATH . 'Common.php')) {
require_once APPPATH . 'Common.php';
}
require_once SYSTEMPATH . 'Common.php';
// Set environment values that would otherwise stop the framework from functioning during tests.
if (! isset($_SERVER['app.baseURL'])) {
$_SERVER['app.baseURL'] = 'http://example.com/';
}
// Load necessary components
require_once SYSTEMPATH . 'Config/AutoloadConfig.php';
require_once APPPATH . 'Config/Autoload.php';
require_once APPPATH . 'Config/Constants.php';
require_once SYSTEMPATH . 'Modules/Modules.php';
require_once APPPATH . 'Config/Modules.php';
require_once SYSTEMPATH . 'Autoloader/Autoloader.php';
require_once SYSTEMPATH . 'Config/BaseService.php';
require_once SYSTEMPATH . 'Config/Services.php';
require_once APPPATH . 'Config/Services.php';
// Use Config\Services as CodeIgniter\Services
if (! class_exists('CodeIgniter\Services', false)) {
class_alias('Config\Services', 'CodeIgniter\Services');
}
// Initialize and register the loader with the SPL autoloader stack.
Services::autoloader()->initialize(new Autoload(), new Modules())->register();
// Now load Composer's if it's available
if (is_file(COMPOSER_PATH)) {
require_once COMPOSER_PATH;
}
// Load environment settings from .env files into $_SERVER and $_ENV
require_once SYSTEMPATH . 'Config/DotEnv.php';
$env = new DotEnv(ROOTPATH);
$env->load();
// Always load the URL helper, it should be used in most of apps.
helper('url');
require_once APPPATH . 'Config/Routes.php';
/**
* @var RouteCollection $routes
*/
$routes->getRoutes('*');