first commit

This commit is contained in:
2024-09-06 13:32:15 -04:00
commit 700e8a2948
2013 changed files with 447887 additions and 0 deletions
+529
View File
@@ -0,0 +1,529 @@
<?php
declare(strict_types=1);
/**
* 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\Email;
use Config\Modules;
use Config\Services;
use Config\Session;
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.
*
* WARNING: Do not override unless you know exactly what you are doing.
* This property may be deprecated in the future.
*
* @var list<string> array of methods
*/
protected $setUpMethods = [
'resetFactories',
'mockCache',
'mockEmail',
'mockSession',
];
/**
* Methods to run during tearDown.
*
* WARNING: This property may be deprecated in the future.
*
* @var list<string> array of methods
*/
protected $tearDownMethods = [];
/**
* Store of identified traits.
*/
private ?array $traits = null;
// --------------------------------------------------------------------
// 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 class-string<Seeder>|list<class-string<Seeder>>
*/
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.
* `null` 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 non-empty-string
*/
protected $DBGroup = 'tests';
/**
* Our database connection.
*
* @var BaseConnection
*/
protected $db;
/**
* Migration Runner instance.
*
* @var MigrationRunner|null
*/
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(bool $initAutoloader = true)
{
Services::reset($initAutoloader);
}
/**
* 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::class)));
}
/**
* Injects the mock session driver into Services
*/
protected function mockSession()
{
$_SESSION = [];
$config = config(Session::class);
$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
*
* @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;
}
/**
* Asserts that there is a log record that contains `$logMessage` in the message.
*/
public function assertLogContains(string $level, string $logMessage, string $message = ''): void
{
$this->assertTrue(
TestLogger::didLog($level, $logMessage, false),
$message ?: sprintf(
'Failed asserting that logs have a record of message containing "%s" with level "%s".',
$logMessage,
$level
)
);
}
/**
* 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 presence of
* a specific header emitted.
*
* @param string $header The leading portion of the header we are looking for
*/
public function assertHeaderEmitted(string $header, bool $ignoreCase = false): void
{
$this->assertNotNull(
$this->getHeaderEmitted($header, $ignoreCase, __METHOD__),
"Didn't find header for {$header}"
);
}
/**
* Hooks into xdebug's headers capture, looking for absence of
* a specific header emitted.
*
* @param string $header The leading portion of the header we don't want to find
*/
public function assertHeaderNotEmitted(string $header, bool $ignoreCase = false): void
{
$this->assertNull(
$this->getHeaderEmitted($header, $ignoreCase, __METHOD__),
"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 float|int $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
*
* @return bool|void
*
* @throws Exception
*/
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) {
return false;
}
}
// --------------------------------------------------------------------
// Utility
// --------------------------------------------------------------------
/**
* Loads up an instance of CodeIgniter
* and gets the environment setup.
*
* @return CodeIgniter
*/
protected function createApplication()
{
// Initialize the autoloader.
service('autoloader')->initialize(new Autoload(), new Modules());
$app = new MockCodeIgniter(new App());
$app->initialize();
return $app;
}
/**
* Return first matching emitted header.
*/
protected function getHeaderEmitted(string $header, bool $ignoreCase = false, string $method = __METHOD__): ?string
{
if (! function_exists('xdebug_get_headers')) {
$this->markTestSkipped($method . '() requires xdebug.');
}
foreach (xdebug_get_headers() as $emittedHeader) {
$found = $ignoreCase
? (stripos($emittedHeader, $header) === 0)
: (str_starts_with($emittedHeader, $header));
if ($found) {
return $emittedHeader;
}
}
return null;
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
/**
* 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 LogicException;
trait ConfigFromArrayTrait
{
/**
* Creates a Config instance from an array.
*
* @template T of \CodeIgniter\Config\BaseConfig
*
* @param class-string<T> $classname Config classname
* @param array<string, mixed> $config
*
* @return T
*/
private function createConfigFromArray(string $classname, array $config)
{
$configObj = new $classname();
foreach ($config as $key => $value) {
if (property_exists($configObj, $key)) {
$configObj->{$key} = $value;
continue;
}
throw new LogicException(
'No such property: ' . $classname . '::$' . $key
);
}
return $configObj;
}
}
+120
View File
@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
/**
* 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(false, 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(bool $exportObjects = false, $options = 0): string
{
return json_encode($this->data, $options);
}
}
+307
View File
@@ -0,0 +1,307 @@
<?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\ResponseInterface;
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 ResponseInterface
*/
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::class);
}
if (! $this->uri instanceof URI) {
$factory = Services::siteurifactory($this->appConfig, service('superglobals'), false);
$this->uri = $factory->createFromGlobals();
}
if (empty($this->request)) {
// Do some acrobatics, so we can use the Request service with our own URI
$tempUri = service('uri');
Services::injectMock('uri', $this->uri);
$this->withRequest(Services::incomingrequest($this->appConfig, false));
// 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 = service('logger');
}
}
/**
* Loads the specified controller, and generates any needed dependencies.
*
* @return $this
*/
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
*
* @return TestResponse
*
* @throws InvalidArgumentException
*/
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;
$this->request->setBody($this->body);
try {
ob_start();
// The controller method param types may not be string.
// So cannot set `declare(strict_types=1)` in this file.
$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 ResponseInterface) {
$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) {
// 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 App $appConfig
*
* @return $this
*/
public function withConfig($appConfig)
{
$this->appConfig = $appConfig;
return $this;
}
/**
* Set controller's request, with method chaining.
*
* @param IncomingRequest $request
*
* @return $this
*/
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 ResponseInterface $response
*
* @return $this
*/
public function withResponse($response)
{
$this->response = $response;
return $this;
}
/**
* Set controller's logger, with method chaining.
*
* @param LoggerInterface $logger
*
* @return $this
*/
public function withLogger($logger)
{
$this->logger = $logger;
return $this;
}
/**
* Set the controller's URI, with method chaining.
*
* @return $this
*/
public function withUri(string $uri)
{
$factory = service('siteurifactory');
$this->uri = $factory->createFromString($uri);
Services::injectMock('uri', $this->uri);
// Update the Request instance, because Request has the SiteURI instance.
$this->request = Services::incomingrequest(null, false);
Services::injectMock('request', $this->request);
return $this;
}
/**
* Set the method's body, with method chaining.
*
* @param string|null $body
*
* @return $this
*/
public function withBody($body)
{
$this->body = $body;
return $this;
}
}
+300
View File
@@ -0,0 +1,300 @@
<?php
declare(strict_types=1);
/**
* 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
*
* @see \CodeIgniter\Test\DOMParserTest
*/
class DOMParser
{
/**
* DOM for the body,
*
* @var DOMDocument
*/
protected $dom;
/**
* Constructor.
*
* @throws BadMethodCallException
*/
public function __construct()
{
if (! extension_loaded('DOM')) {
throw new BadMethodCallException('DOM extension is required, but not currently loaded.'); // @codeCoverageIgnore
}
$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)
{
// DOMDocument::loadHTML() will treat your string as being in ISO-8859-1
// (the HTTP/1.1 default character set) unless you tell it otherwise.
// https://stackoverflow.com/a/8218649
// So encode characters to HTML numeric string references.
$content = mb_encode_numericentity($content, [0x80, 0x10FFFF, 0, 0x1FFFFF], '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 $this
*/
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.
*/
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.
*/
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;
}
/**
* Checks to see if the XPath can be found.
*/
public function seeXPath(string $path): bool
{
$xpath = new DOMXPath($this->dom);
return (bool) $xpath->query($path)->length;
}
/**
* Checks to see if the XPath can't be found.
*/
public function dontSeeXPath(string $path): bool
{
return ! $this->seeXPath($path);
}
/**
* Search the DOM using an XPath expression.
*
* @return DOMNodeList|false
*/
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 (isset($selector['id'])) {
$path = ($selector['tag'] === '')
? "id(\"{$selector['id']}\")"
: "//{$selector['tag']}[@id=\"{$selector['id']}\"]";
}
// By Class
elseif (isset($selector['class'])) {
$path = ($selector['tag'] === '')
? "//*[@class=\"{$selector['class']}\"]"
: "//{$selector['tag']}[@class=\"{$selector['class']}\"]";
}
// By tag only
elseif ($selector['tag'] !== '') {
$path = "//{$selector['tag']}";
}
if (isset($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 ($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{tag: string, id: string|null, class: string|null, attr: array<string, string>|null}
*/
public function parseSelector(string $selector)
{
$id = null;
$class = null;
$attr = null;
// ID?
if (str_contains($selector, '#')) {
[$tag, $id] = explode('#', $selector);
}
// Attribute
elseif (str_contains($selector, '[') && str_contains($selector, ']')) {
$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 (str_contains($selector, '.')) {
[$tag, $class] = explode('.', $selector);
}
// Otherwise, assume the entire string is our tag
else {
$tag = $selector;
}
return [
'tag' => $tag,
'id' => $id,
'class' => $class,
'attr' => $attr,
];
}
}
+383
View File
@@ -0,0 +1,383 @@
<?php
declare(strict_types=1);
/**
* 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;
use PHPUnit\Framework\Attributes\AfterClass;
/**
* 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.
*
* @return void
*/
protected function setUpDatabase()
{
$this->loadDependencies();
$this->setUpMigrate();
$this->setUpSeed();
}
/**
* Runs the trait set up methods.
*
* @return void
*/
protected function tearDownDatabase()
{
$this->clearInsertCache();
}
/**
* Load any database test dependencies.
*
* @return void
*/
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, false);
$this->migrations->setSilent(false);
}
if ($this->seeder === null) {
$this->seeder = Database::seeder($this->DBGroup);
$this->seeder->setSilent(true);
}
}
// --------------------------------------------------------------------
// Migrations
// --------------------------------------------------------------------
/**
* Migrate on setUp
*
* @return void
*/
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
*
* @return void
*/
protected function regressDatabase()
{
if ($this->migrate === false) {
return;
}
// If no namespace was specified then rollback all
if ($this->namespace === null) {
$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
*
* @return void
*/
protected function migrateDatabase()
{
if ($this->migrate === false) {
return;
}
// If no namespace was specified then migrate all
if ($this->namespace === null) {
$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
*
* @return void
*/
protected function setUpSeed()
{
if ($this->seedOnce === false || self::$doneSeed === false) {
$this->runSeeds();
}
}
/**
* Run seeds as defined by the class
*
* @return void
*/
protected function runSeeds()
{
if ($this->seed !== '') {
if ($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.
*
* @return void
*/
public function seed(string $name)
{
$this->seeder->call($name);
}
// --------------------------------------------------------------------
// Utility
// --------------------------------------------------------------------
/**
* Reset $doneMigration and $doneSeed
*
* @return void
*/
#[AfterClass]
public static function resetMigrationSeedCount()
{
self::$doneMigration = false;
self::$doneSeed = false;
}
/**
* Removes any rows inserted via $this->hasInDatabase()
*
* @return void
*/
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', $this->db::class);
return new $builderClass($tableName, $this->db);
}
/**
* Fetches a single column from a database row with criteria
* matching $where.
*
* @param array<string, mixed> $where
*
* @return bool
*
* @throws DatabaseException
*/
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.
*
* @param array<string, mixed> $where
*
* @return void
*
* @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.
*
* @param array<string, mixed> $where
*
* @return void
*/
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.
*
* @param array<string, mixed> $data
*
* @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.
*
* @param array<string, mixed> $where
*
* @return void
*
* @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.');
}
/**
* Sets $DBDebug to false.
*
* WARNING: this value will persist! take care to roll it back.
*/
protected function disableDBDebug(): void
{
$this->setPrivateProperty($this->db, 'DBDebug', false);
}
/**
* Sets $DBDebug to true.
*/
protected function enableDBDebug(): void
{
$this->setPrivateProperty($this->db, 'DBDebug', true);
}
}
+616
View File
@@ -0,0 +1,616 @@
<?php
declare(strict_types=1);
/**
* 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\Exceptions\FrameworkException;
use CodeIgniter\I18n\Time;
use CodeIgniter\Model;
use Config\App;
use Faker\Factory;
use Faker\Generator;
use InvalidArgumentException;
use RuntimeException;
/**
* Fabricator
*
* Bridge class for using Faker to create example data based on
* model specifications.
*
* @see \CodeIgniter\Test\FabricatorTest
*/
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;
/**
* Fields to be modified before applying any formatter.
*
* @var array{
* unique: array<non-empty-string, array{reset: bool, maxRetries: int}>,
* optional: array<non-empty-string, array{weight: float, default: mixed}>,
* valid: array<non-empty-string, array{validator: Closure(mixed): bool|null, maxRetries: int}>
* }
*/
private array $modifiedFields = ['unique' => [], 'optional' => [], 'valid' => []];
/**
* 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::class)->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 (isset($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 ! isset(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;
}
/**
* Set a field to be unique.
*
* @param bool $reset If set to true, resets the list of existing values
* @param int $maxRetries Maximum number of retries to find a unique value,
* After which an OverflowException is thrown.
*/
public function setUnique(string $field, bool $reset = false, int $maxRetries = 10000): static
{
$this->modifiedFields['unique'][$field] = compact('reset', 'maxRetries');
return $this;
}
/**
* Set a field to be optional.
*
* @param float $weight A probability between 0 and 1, 0 means that we always get the default value.
*/
public function setOptional(string $field, float $weight = 0.5, mixed $default = null): static
{
$this->modifiedFields['optional'][$field] = compact('weight', 'default');
return $this;
}
/**
* Set a field to be valid using a callback.
*
* @param Closure(mixed): bool|null $validator A function returning true for valid values
* @param int $maxRetries Maximum number of retries to find a valid value,
* After which an OverflowException is thrown.
*/
public function setValid(string $field, ?Closure $validator = null, int $maxRetries = 10000): static
{
$this->modifiedFields['valid'][$field] = compact('validator', 'maxRetries');
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 (isset($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) {
// 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
*
* @return array An array of faked data
*
* @throws RuntimeException
*/
public function makeArray()
{
if ($this->formatters !== null) {
$result = [];
foreach ($this->formatters as $field => $formatter) {
$faker = $this->faker;
if (isset($this->modifiedFields['unique'][$field])) {
$faker = $faker->unique(
$this->modifiedFields['unique'][$field]['reset'],
$this->modifiedFields['unique'][$field]['maxRetries']
);
}
if (isset($this->modifiedFields['optional'][$field])) {
$faker = $faker->optional(
$this->modifiedFields['optional'][$field]['weight'],
$this->modifiedFields['optional'][$field]['default']
);
}
if (isset($this->modifiedFields['valid'][$field])) {
$faker = $faker->valid(
$this->modifiedFields['valid'][$field]['validator'],
$this->modifiedFields['valid'][$field]['maxRetries']
);
}
$result[$field] = $faker->format($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
*
* @return object An instance of the class with faked data
*
* @throws RuntimeException
*/
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
*
* @return array|object An array or object (based on returnType), or an array of returnTypes
*
* @throws FrameworkException
*/
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)
{
$datetime = match ($this->model->dateFormat) {
'datetime' => date('Y-m-d H:i:s'),
'date' => date('Y-m-d'),
default => Time::now()->getTimestamp(),
};
// Determine which fields we will need
$fields = [];
if ($this->model->useTimestamps) {
$fields[$this->model->createdField] = $datetime;
$fields[$this->model->updatedField] = $datetime;
}
if ($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;
}
}
+429
View File
@@ -0,0 +1,429 @@
<?php
declare(strict_types=1);
/**
* 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\Exceptions\RedirectException;
use CodeIgniter\HTTP\IncomingRequest;
use CodeIgniter\HTTP\Method;
use CodeIgniter\HTTP\Request;
use CodeIgniter\HTTP\SiteURI;
use CodeIgniter\HTTP\URI;
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|null $routes Array to set routes
*
* @return $this
*/
protected function withRoutes(?array $routes = null)
{
$collection = service('routes');
if ($routes !== null) {
$collection->resetRoutes();
foreach ($routes as $route) {
if ($route[0] === strtolower($route[0])) {
@trigger_error(
'Passing lowercase HTTP method "' . $route[0] . '" is deprecated.'
. ' Use uppercase HTTP method like "' . strtoupper($route[0]) . '".',
E_USER_DEPRECATED
);
}
/**
* @TODO For backward compatibility. Remove strtolower() in the future.
* @deprecated 4.5.0
*/
$method = strtolower($route[0]);
if (isset($route[3])) {
$collection->{$method}($route[1], $route[2], $route[3]);
} else {
$collection->{$method}($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 string $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.
*
* @param string $method HTTP verb
*
* @return TestResponse
*/
public function call(string $method, string $path, ?array $params = null)
{
if ($method === strtolower($method)) {
@trigger_error(
'Passing lowercase HTTP method "' . $method . '" is deprecated.'
. ' Use uppercase HTTP method like "' . strtoupper($method) . '".',
E_USER_DEPRECATED
);
}
/**
* @deprecated 4.5.0
* @TODO remove this in the future.
*/
$method = strtoupper($method);
// Simulate having a blank session
$_SESSION = [];
$_SERVER['REQUEST_METHOD'] = $method;
$request = $this->setupRequest($method, $path);
$request = $this->setupHeaders($request);
$name = strtolower($method);
$request = $this->populateGlobals($name, $request, $params);
$request = $this->setRequestBody($request, $params);
// Initialize the RouteCollection
if (! $routes = $this->routes) {
$routes = service('routes')->loadRoutes();
}
$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));
// Make sure validation is reset between tests
Services::injectMock('validation', Services::validation(null, false));
$response = $this->app
->setContext('web')
->setRequest($request)
->run($routes, true);
// Reset directory if it has been set
service('router')->setDirectory(null);
return new TestResponse($response);
}
/**
* Performs a GET request.
*
* @param string $path URI path relative to baseURL. May include query.
*
* @return TestResponse
*
* @throws RedirectException
* @throws Exception
*/
public function get(string $path, ?array $params = null)
{
return $this->call(Method::GET, $path, $params);
}
/**
* Performs a POST request.
*
* @return TestResponse
*
* @throws RedirectException
* @throws Exception
*/
public function post(string $path, ?array $params = null)
{
return $this->call(Method::POST, $path, $params);
}
/**
* Performs a PUT request
*
* @return TestResponse
*
* @throws RedirectException
* @throws Exception
*/
public function put(string $path, ?array $params = null)
{
return $this->call(Method::PUT, $path, $params);
}
/**
* Performss a PATCH request
*
* @return TestResponse
*
* @throws RedirectException
* @throws Exception
*/
public function patch(string $path, ?array $params = null)
{
return $this->call(Method::PATCH, $path, $params);
}
/**
* Performs a DELETE request.
*
* @return TestResponse
*
* @throws RedirectException
* @throws Exception
*/
public function delete(string $path, ?array $params = null)
{
return $this->call(Method::DELETE, $path, $params);
}
/**
* Performs an OPTIONS request.
*
* @return TestResponse
*
* @throws RedirectException
* @throws Exception
*/
public function options(string $path, ?array $params = null)
{
return $this->call(Method::OPTIONS, $path, $params);
}
/**
* Setup a Request object to use so that CodeIgniter
* won't try to auto-populate some of the items.
*
* @param string $method HTTP verb
*/
protected function setupRequest(string $method, ?string $path = null): IncomingRequest
{
$config = config(App::class);
$uri = new SiteURI($config);
// $path may have a query in it
$path = URI::removeDotSegments($path);
$parts = explode('?', $path);
$path = $parts[0];
$query = $parts[1] ?? '';
$superglobals = service('superglobals');
$superglobals->setServer('QUERY_STRING', $query);
$uri->setPath($path);
$uri->setQuery($query);
Services::injectMock('uri', $uri);
$request = Services::incomingrequest($config, false);
$request->setMethod($method);
$request->setProtocolVersion('1.1');
if ($config->forceGlobalSecureRequests) {
$_SERVER['HTTPS'] = 'test';
$server = $request->getServer();
$server['HTTPS'] = 'test';
$request->setGlobal('server', $server);
}
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.
*
* @param string $name Superglobal name (lowercase)
* @param non-empty-array|null $params
*
* @return Request
*
* @throws ReflectionException
*/
protected function populateGlobals(string $name, Request $request, ?array $params = null)
{
// $params should set the query vars if present,
// otherwise set it from the URL.
$get = ($params !== null && $params !== [] && $name === 'get')
? $params
: $this->getPrivateProperty($request->getUri(), 'query');
$request->setGlobal('get', $get);
if ($name === 'get') {
$request->setGlobal('request', $request->fetchGlobal('get'));
}
if ($name === 'post') {
$request->setGlobal($name, $params);
$request->setGlobal(
'request',
$request->fetchGlobal('post') + $request->fetchGlobal('get')
);
}
$_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.
*/
protected function setRequestBody(Request $request, ?array $params = null): Request
{
if ($this->requestBody !== '') {
$request->setBody($this->requestBody);
}
if ($this->bodyFormat !== '') {
$formatMime = '';
if ($this->bodyFormat === 'json') {
$formatMime = 'application/json';
} elseif ($this->bodyFormat === 'xml') {
$formatMime = 'application/xml';
}
if ($formatMime !== '') {
$request->setHeader('Content-Type', $formatMime);
}
if ($params !== null && $formatMime !== '') {
$formatted = service('format')->getFormatter($formatMime)->format($params);
// "withBodyFormat() and $params of call()" has higher priority than withBody().
$request->setBody($formatted);
}
}
return $request;
}
}
+309
View File
@@ -0,0 +1,309 @@
<?php
declare(strict_types=1);
/**
* 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 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 ??= clone service('request');
$this->response ??= clone service('response');
// Create our config and Filters instance to reuse for performance
$this->filtersConfig ??= config(FiltersConfig::class);
$this->filters ??= new Filters($this->filtersConfig, $this->request, $this->response);
if ($this->collection === null) {
$this->collection = service('routes')->loadRoutes();
}
$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 ($filter instanceof FilterInterface) {
$filterInstances = [$filter];
}
if (is_string($filter)) {
// Check for an alias (no namespace)
if (! str_contains($filter, '\\')) {
if (! isset($this->filtersConfig->aliases[$filter])) {
throw new RuntimeException("No filter found with alias '{$filter}'");
}
$filterClasses = (array) $this->filtersConfig->aliases[$filter];
} else {
// FQCN
$filterClasses = [$filter];
}
$filterInstances = [];
foreach ($filterClasses as $class) {
// Get an instance
$filter = new $class();
if (! $filter instanceof FilterInterface) {
throw FilterException::forIncorrectInterface($filter::class);
}
$filterInstances[] = $filter;
}
}
$request = clone $this->request;
if ($position === 'before') {
return static function (?array $params = null) use ($filterInstances, $request) {
foreach ($filterInstances as $filter) {
$result = $filter->before($request, $params);
// @TODO The following logic is in Filters class.
// Should use Filters class.
if ($result instanceof RequestInterface) {
$request = $result;
continue;
}
if ($result instanceof ResponseInterface) {
return $result;
}
if (empty($result)) {
continue;
}
}
return $result;
};
}
$response = clone $this->response;
return static function (?array $params = null) use ($filterInstances, $request, $response) {
foreach ($filterInstances as $filter) {
$result = $filter->after($request, $response, $params);
// @TODO The following logic is in Filters class.
// Should use Filters class.
if ($result instanceof ResponseInterface) {
$response = $result;
continue;
}
}
return $result;
};
}
/**
* 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 list<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 ($routeFilters = $this->collection->getFiltersForRoute($route)) {
$this->filters->enableFilters($routeFilters, $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) . '.',
);
}
}
+104
View File
@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
/**
* 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 = '';
protected static bool $registered = false;
/**
* @var resource|null
*/
private static $err;
/**
* @var resource|null
*/
private static $out;
/**
* 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;
}
public static function registration(): void
{
if (! static::$registered) {
static::$registered = stream_filter_register('CITestStreamFilter', self::class); // @codeCoverageIgnore
}
static::$buffer = '';
}
public static function addErrorFilter(): void
{
self::removeFilter(self::$err);
self::$err = stream_filter_append(STDERR, 'CITestStreamFilter');
}
public static function addOutputFilter(): void
{
self::removeFilter(self::$out);
self::$out = stream_filter_append(STDOUT, 'CITestStreamFilter');
}
public static function removeErrorFilter(): void
{
self::removeFilter(self::$err);
}
public static function removeOutputFilter(): void
{
self::removeFilter(self::$out);
}
/**
* @param resource $stream
*/
protected static function removeFilter(&$stream): void
{
if (is_resource($stream)) {
stream_filter_remove($stream);
$stream = null;
}
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
/**
* 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;
trait IniTestTrait
{
private array $iniSettings = [];
private function backupIniValues(array $keys): void
{
foreach ($keys as $key) {
$this->iniSettings[$key] = ini_get($key);
}
}
private function restoreIniValues(): void
{
foreach ($this->iniSettings as $key => $value) {
ini_set($key, $value);
}
$this->iniSettings = [];
}
}
@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
/**
* 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 CodeIgniter\BaseModel;
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
*
* @phpstan-import-type row_array from BaseModel
*/
interface FabricatorModel
{
/**
* Fetches the row of database from $this->table with a primary key
* matching $id.
*
* @param int|list<int|string>|string|null $id One primary key or an array of primary keys
*
* @phpstan-return ($id is int|string ? row_array|object|null : list<row_array|object>)
*/
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|null $row
* @phpstan-param row_array|object|null $row
* @param bool $returnID Whether insert ID should be returned or not.
*
* @return bool|int|string
*
* @throws ReflectionException
*/
public function insert($row = 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 bool $val
*
* @return Model
*/
// public function withDeleted($val = true);
/**
* Faked data for Fabricator.
*
* @param Generator $faker
*
* @return array|object
*/
// public function fake(Generator &$faker);
}
+30
View File
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
/**
* 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 string $baseURL = 'http://example.com/';
public string $uriProtocol = 'REQUEST_URI';
public array $proxyIPs = [];
public bool $CSPEnabled = false;
public string $defaultLocale = 'en';
public bool $negotiateLocale = false;
public array $supportedLocales = [
'en',
'es',
];
}
+28
View File
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
/**
* 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();
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
/**
* 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
{
protected $supportedIgnoreStatements = [
'update' => 'IGNORE',
'insert' => 'IGNORE',
'delete' => 'IGNORE',
];
}
+36
View File
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
/**
* 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 string $baseURL = 'http://example.com/';
public string $uriProtocol = 'REQUEST_URI';
public array $proxyIPs = [];
public string $CSRFTokenName = 'csrf_test_name';
public string $CSRFCookieName = 'csrf_cookie_name';
public int $CSRFExpire = 7200;
public bool $CSRFRegenerate = true;
public $CSRFExcludeURIs = ['http://example.com'];
public string $CSRFSameSite = 'Lax';
public bool $CSPEnabled = false;
public string $defaultLocale = 'en';
public bool $negotiateLocale = false;
public array $supportedLocales = [
'en',
'es',
];
}
+58
View File
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
/**
* 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
{
$this->response = clone $this->responseOrig;
// 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;
}
}
+307
View File
@@ -0,0 +1,307 @@
<?php
declare(strict_types=1);
/**
* 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 CodeIgniter\I18n\Time;
use PHPUnit\Framework\Assert;
class MockCache extends BaseHandler implements CacheInterface
{
/**
* Mock cache storage.
*
* @var array<string, mixed>
*/
protected $cache = [];
/**
* Expiration times.
*
* @var ?list<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.
*
* @return void
*/
public function initialize()
{
}
/**
* Attempts to fetch an item from the cache store.
*
* @param string $key Cache item name
*
* @return bool|null
*/
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 bool|null
*/
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)
*
* @return bool
*/
public function save(string $key, $value, int $ttl = 60)
{
if ($this->bypass) {
return false;
}
$key = static::validateKey($key, $this->prefix);
$this->cache[$key] = $value;
$this->expirations[$key] = $ttl > 0 ? Time::now()->getTimestamp() + $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 ($data === null) {
$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 ($data === null) {
$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 list<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::now()->getTimestamp()) {
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.
*
* @return void
*/
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
*
* @return void
*/
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 ($item === null) {
$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.
*
* @return void
*/
public function assertMissing(string $key)
{
Assert::assertArrayNotHasKey($key, $this->cache, "The cached item named `{$key}` exists.");
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
/**
* 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 ?string $context = 'web';
/**
* @param int $code
*
* @deprecated 4.4.0 No longer Used. Moved to index.php.
*/
protected function callExit($code)
{
// Do not call exit() in testing.
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
/**
* 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;
}
}
+258
View File
@@ -0,0 +1,258 @@
<?php
declare(strict_types=1);
/**
* 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;
/**
* @extends BaseConnection<object|resource, object|resource>
*/
class MockConnection extends BaseConnection
{
/**
* @var array{connect?: mixed, execute?: bool|object}
*/
protected $returnValues = [];
/**
* Database schema for Postgre and SQLSRV
*
* @var string
*/
protected $schema;
public $database;
public $lastQuery;
/**
* @param mixed $return
*
* @return $this
*/
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 ($this->swapPre !== '' && $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($sql)) {
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 bool
*/
public function setDatabase(string $databaseName)
{
$this->database = $databaseName;
return true;
}
/**
* 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 bool|object
*/
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.
*
* @param string|null $tableName If $tableName is provided will return only this table if exists.
*/
protected function _listTables(bool $constrainByPrefix = false, ?string $tableName = null): 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.
*
* @return void
*/
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;
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
/**
* 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;
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
/**
* 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;
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
/**
* 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;
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
/**
* 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
{
}
+140
View File
@@ -0,0 +1,140 @@
<?php
declare(strict_types=1);
/**
* 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\CLI\InputOutput;
use CodeIgniter\Test\Filters\CITestStreamFilter;
use CodeIgniter\Test\PhpStreamWrapper;
use InvalidArgumentException;
use LogicException;
final class MockInputOutput extends InputOutput
{
/**
* String to be entered by the user.
*
* @var list<string>
*/
private array $inputs = [];
/**
* Output lines.
*
* @var array<int, string>
* @phpstan-var list<string>
*/
private array $outputs = [];
/**
* Sets user inputs.
*
* @param array<int, string> $inputs
* @phpstan-param list<string> $inputs
*/
public function setInputs(array $inputs): void
{
$this->inputs = $inputs;
}
/**
* Gets the item from the output array.
*
* @param int|null $index The output array index. If null, returns all output
* string. If negative int, returns the last $index-th
* item.
*/
public function getOutput(?int $index = null): string
{
if ($index === null) {
return implode('', $this->outputs);
}
if (array_key_exists($index, $this->outputs)) {
return $this->outputs[$index];
}
if ($index < 0) {
$i = count($this->outputs) + $index;
if (array_key_exists($i, $this->outputs)) {
return $this->outputs[$i];
}
}
throw new InvalidArgumentException(
'No such index in output: ' . $index . ', the last index is: '
. (count($this->outputs) - 1)
);
}
/**
* Returns the outputs array.
*/
public function getOutputs(): array
{
return $this->outputs;
}
private function addStreamFilters(): void
{
CITestStreamFilter::registration();
CITestStreamFilter::addOutputFilter();
CITestStreamFilter::addErrorFilter();
}
private function removeStreamFilters(): void
{
CITestStreamFilter::removeOutputFilter();
CITestStreamFilter::removeErrorFilter();
}
public function input(?string $prefix = null): string
{
if ($this->inputs === []) {
throw new LogicException(
'No input data. Specifiy input data with `MockInputOutput::setInputs()`.'
);
}
$input = array_shift($this->inputs);
$this->addStreamFilters();
PhpStreamWrapper::register();
PhpStreamWrapper::setContent($input);
$userInput = parent::input($prefix);
$this->outputs[] = CITestStreamFilter::$buffer . $input . PHP_EOL;
PhpStreamWrapper::restore();
$this->removeStreamFilters();
if ($input !== $userInput) {
throw new LogicException($input . '!==' . $userInput);
}
return $input;
}
public function fwrite($handle, string $string): void
{
$this->addStreamFilters();
parent::fwrite($handle, $string);
$this->outputs[] = CITestStreamFilter::$buffer;
$this->removeStreamFilters();
}
}
+58
View File
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
/**
* 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;
}
}
+105
View File
@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
/**
* 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 Tests\Support\Log\Handlers\TestHandler;
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 server, 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
TestHandler::class => [
// The log levels that this handler will handle.
'handles' => [
'critical',
'alert',
'emergency',
'debug',
'error',
'info',
'notice',
'warning',
],
// Logging Directory Path
'path' => '',
],
];
}
+20
View File
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
/**
* 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
{
}
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
/**
* 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;
}
}
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
/**
* 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;
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
/**
* 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;
}
}
+103
View File
@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
/**
* 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;
use stdClass;
/**
* @extends BaseResult<object|resource, object|resource>
*/
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 void
*/
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 bool
*/
public function dataSeek($n = 0)
{
return true;
}
/**
* 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|stdClass
*/
protected function fetchObject($className = 'stdClass')
{
return new $className();
}
/**
* Gets the number of fields in the result set.
*/
public function getNumRows(): int
{
return 0;
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
/**
* 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);
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
/**
* 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());
}
}
+73
View File
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
/**
* 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\I18n\Time;
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 list<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->config->expiration === 0 ? 0 : Time::now()->getTimestamp() + $this->config->expiration;
$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::now()->getTimestamp();
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
/**
* 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');
}
}
+77
View File
@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
/**
* 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;
/**
* StreamWrapper for php protocol
*
* This class is used for mocking `php://stdin`.
*
* See https://www.php.net/manual/en/class.streamwrapper.php
*/
final class PhpStreamWrapper
{
/**
* @var resource|null
*/
public $context;
private static string $content = '';
private int $position = 0;
public static function setContent(string $content)
{
self::$content = $content;
}
public static function register()
{
stream_wrapper_unregister('php');
stream_wrapper_register('php', self::class);
}
public static function restore()
{
stream_wrapper_restore('php');
}
public function stream_open(): bool
{
return true;
}
/**
* @return false|string
*/
public function stream_read(int $count)
{
$return = substr(self::$content, $this->position, $count);
$this->position += strlen($return);
return $return;
}
/**
* @return array|false
*/
public function stream_stat()
{
return [];
}
public function stream_eof(): bool
{
return $this->position >= strlen(self::$content);
}
}
+103
View File
@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
/**
* 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
*
* @return Closure
*
* @throws ReflectionException
*/
public static function getPrivateMethodInvoker($obj, $method)
{
$refMethod = new ReflectionMethod($obj, $method);
$refMethod->setAccessible(true);
$obj = (gettype($obj) === 'object') ? $obj : null;
return static fn (...$args) => $refMethod->invokeArgs($obj, $args);
}
/**
* Find an accessible property.
*
* @param object|string $obj
* @param string $property
*
* @return ReflectionProperty
*
* @throws ReflectionException
*/
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);
if (is_object($obj)) {
$refProperty->setValue($obj, $value);
} else {
$refProperty->setValue(null, $value);
}
}
/**
* Retrieve a private property.
*
* @param object|string $obj object or class name
* @param string $property property name
*
* @return mixed value
*
* @throws ReflectionException
*/
public static function getPrivateProperty($obj, $property)
{
$refProperty = self::getAccessibleRefProperty($obj, $property);
return is_string($obj) ? $refProperty->getValue() : $refProperty->getValue($obj);
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
/**
* 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\Test\Filters\CITestStreamFilter;
trait StreamFilterTrait
{
protected function setUpStreamFilterTrait(): void
{
CITestStreamFilter::registration();
CITestStreamFilter::addOutputFilter();
CITestStreamFilter::addErrorFilter();
}
protected function tearDownStreamFilterTrait(): void
{
CITestStreamFilter::removeOutputFilter();
CITestStreamFilter::removeErrorFilter();
}
protected function getStreamFilterBuffer(): string
{
return CITestStreamFilter::$buffer;
}
protected function resetStreamFilterBuffer(): void
{
CITestStreamFilter::$buffer = '';
}
}
+109
View File
@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
/**
* 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;
use Stringable;
/**
* @see \CodeIgniter\Test\TestLoggerTest
*/
class TestLogger extends Logger
{
/**
* @var list<array{level: mixed, message: string, file: string|null}>
*/
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 mixed $level
* @param string $message
*/
public function log($level, string|Stringable $message, array $context = []): void
{
// 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.
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, bool $useExactComparison = true)
{
$lowerLevel = strtolower($level);
foreach (self::$op_logs as $log) {
if (strtolower($log['level']) !== $lowerLevel) {
continue;
}
if ($useExactComparison) {
if ($log['message'] === $message) {
return true;
}
continue;
}
if (str_contains($log['message'], $message)) {
return true;
}
}
return false;
}
/**
* Expose filenames.
*
* @param string $file
*
* @return string
*
* @deprecated No longer needed as underlying protected method is also deprecated.
*/
public function cleanup($file)
{
return clean_path($file);
}
}
+495
View File
@@ -0,0 +1,495 @@
<?php
declare(strict_types=1);
/**
* 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 CodeIgniter\I18n\Time;
use PHPUnit\Framework\Assert;
use PHPUnit\Framework\Constraint\IsEqual;
/**
* Consolidated response processing
* for test results.
*
* @mixin DOMParser
*
* @see \CodeIgniter\Test\TestResponseTest
*/
class TestResponse
{
/**
* 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;
}
$body = (string) $this->response->getBody();
// Empty bodies are not considered valid, unless in redirects
return ! ($status < 300 && $body === '');
}
/**
* Asserts that the status is a specific value.
*/
public function assertStatus(int $code): void
{
Assert::assertSame($code, $this->response->getStatusCode());
}
/**
* Asserts that the Response is considered OK.
*/
public function assertOK(): void
{
Assert::assertTrue(
$this->isOK(),
"{$this->response->getStatusCode()} is not a successful status code, or Response has an empty body."
);
}
/**
* Asserts that the Response is considered not OK.
*/
public function assertNotOK(): void
{
Assert::assertFalse(
$this->isOK(),
"{$this->response->getStatusCode()} is an unexpected successful status code, or Response body has 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.
*/
public function assertRedirect(): void
{
Assert::assertTrue($this->isRedirect(), 'Response is not a redirect or instance of RedirectResponse.');
}
/**
* Assert that a given response was a redirect
* and it was redirect to a specific URI.
*/
public function assertRedirectTo(string $uri): void
{
$this->assertRedirect();
$uri = trim(strtolower($uri));
$redirectUri = strtolower($this->getRedirectUrl());
$matches = $uri === $redirectUri
|| strtolower(site_url($uri)) === $redirectUri
|| $uri === site_url($redirectUri);
Assert::assertTrue($matches, "Redirect URL '{$uri}' does not match '{$redirectUri}'.");
}
/**
* Assert that the given response was not a redirect.
*/
public function assertNotRedirect(): void
{
Assert::assertFalse($this->isRedirect(), 'Response is an unexpected redirect or instance of 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 its value.
*
* @param mixed $value
*/
public function assertSessionHas(string $key, $value = null): void
{
Assert::assertArrayHasKey($key, $_SESSION, "Key '{$key}' is not in the current \$_SESSION");
if ($value === null) {
return;
}
if (is_scalar($value)) {
Assert::assertSame($value, $_SESSION[$key], "The value of key '{$key}' ({$value}) does not match expected value.");
return;
}
Assert::assertSame($value, $_SESSION[$key], "The value of key '{$key}' does not match expected value.");
}
/**
* Asserts the session is missing $key.
*/
public function assertSessionMissing(string $key): void
{
Assert::assertArrayNotHasKey($key, $_SESSION, "Key '{$key}' should not be present in \$_SESSION.");
}
// --------------------------------------------------------------------
// Headers
// --------------------------------------------------------------------
/**
* Asserts that the Response contains a specific header.
*
* @param string|null $value
*/
public function assertHeader(string $key, $value = null): void
{
Assert::assertTrue($this->response->hasHeader($key), "Header '{$key}' is not a valid Response header.");
if ($value !== null) {
Assert::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.
*/
public function assertHeaderMissing(string $key): void
{
Assert::assertFalse($this->response->hasHeader($key), "Header '{$key}' should not be in the Response headers.");
}
// --------------------------------------------------------------------
// Cookies
// --------------------------------------------------------------------
/**
* Asserts that the response has the specified cookie.
*
* @param string|null $value
*/
public function assertCookie(string $key, $value = null, string $prefix = ''): void
{
Assert::assertTrue($this->response->hasCookie($key, $value, $prefix), "Cookie named '{$key}' is not found.");
}
/**
* Assert the Response does not have the specified cookie set.
*/
public function assertCookieMissing(string $key): void
{
Assert::assertFalse($this->response->hasCookie($key), "Cookie named '{$key}' should not be set.");
}
/**
* Asserts that a cookie exists and has an expired time.
*/
public function assertCookieExpired(string $key, string $prefix = ''): void
{
Assert::assertTrue($this->response->hasCookie($key, null, $prefix));
Assert::assertGreaterThan(
Time::now()->getTimestamp(),
$this->response->getCookie($key, $prefix)->getExpiresTimestamp()
);
}
// --------------------------------------------------------------------
// JSON
// --------------------------------------------------------------------
/**
* Returns the response's body as JSON
*
* @return false|string
*/
public function getJSON()
{
$response = $this->response->getJSON();
if ($response === null) {
return false;
}
return $response;
}
/**
* Test that the response contains a matching JSON fragment.
*/
public function assertJSONFragment(array $fragment, bool $strict = false): void
{
$json = json_decode($this->getJSON(), true);
Assert::assertIsArray($json, 'Response is not a valid JSON.');
$patched = array_replace_recursive($json, $fragment);
if ($strict) {
Assert::assertSame($json, $patched, 'Response does not contain a matching JSON fragment.');
return;
}
Assert::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|object|string $test
*/
public function assertJSONExact($test): void
{
$json = $this->getJSON();
if (is_object($test)) {
$test = method_exists($test, 'toArray') ? $test->toArray() : (array) $test;
}
if (is_array($test)) {
$test = service('format')->getFormatter('application/json')->format($test);
}
Assert::assertJsonStringEqualsJsonString($test, $json, 'Response does not contain matching JSON.');
}
// --------------------------------------------------------------------
// XML Methods
// --------------------------------------------------------------------
/**
* Returns the response' body as XML
*
* @return bool|string|null
*/
public function getXML()
{
return $this->response->getXML();
}
// --------------------------------------------------------------------
// DomParser
// --------------------------------------------------------------------
/**
* Assert that the desired text can be found in the result body.
*/
public function assertSee(?string $search = null, ?string $element = null): void
{
Assert::assertTrue(
$this->domParser->see($search, $element),
"Text '{$search}' is not seen in response."
);
}
/**
* Asserts that we do not see the specified text.
*/
public function assertDontSee(?string $search = null, ?string $element = null): void
{
Assert::assertTrue(
$this->domParser->dontSee($search, $element),
"Text '{$search}' is unexpectedly seen in response."
);
}
/**
* Assert that we see an element selected via a CSS selector.
*/
public function assertSeeElement(string $search): void
{
Assert::assertTrue(
$this->domParser->seeElement($search),
"Element with selector '{$search}' is not seen in response."
);
}
/**
* Assert that we do not see an element selected via a CSS selector.
*/
public function assertDontSeeElement(string $search): void
{
Assert::assertTrue(
$this->domParser->dontSeeElement($search),
"Element with selector '{$search}' is unexpectedly seen in response.'"
);
}
/**
* Assert that we see a link with the matching text and/or class.
*/
public function assertSeeLink(string $text, ?string $details = null): void
{
Assert::assertTrue(
$this->domParser->seeLink($text, $details),
"Anchor tag with text '{$text}' is not seen in response."
);
}
/**
* Assert that we see an input with name/value.
*/
public function assertSeeInField(string $field, ?string $value = null): void
{
Assert::assertTrue(
$this->domParser->seeInField($field, $value),
"Input named '{$field}' with value '{$value}' is not seen in response."
);
}
/**
* Forward any unrecognized method calls to our DOMParser instance.
*
* @param list<mixed> $params
*/
public function __call(string $function, array $params): mixed
{
if (method_exists($this->domParser, $function)) {
return $this->domParser->{$function}(...$params);
}
return null;
}
}
+91
View File
@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
/**
* 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\Boot;
use Config\Paths;
use Config\Services;
error_reporting(E_ALL);
ini_set('display_errors', '1');
ini_set('display_startup_errors', '1');
/*
* ---------------------------------------------------------------
* DEFINE ENVIRONMENT
* ---------------------------------------------------------------
*/
// Make sure it recognizes that we're testing.
$_SERVER['CI_ENVIRONMENT'] = 'testing';
define('ENVIRONMENT', 'testing');
defined('CI_DEBUG') || define('CI_DEBUG', true);
/*
* ---------------------------------------------------------------
* SET UP OUR PATH CONSTANTS
* ---------------------------------------------------------------
*
* The path constants provide convenient access to the folders
* throughout the application. We have to set them up here
* so they are available in the config files that are loaded.
*/
// 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 OUR PATHS CONFIG FILE
// 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('ROOTPATH') || define('ROOTPATH', realpath(APPPATH . '../') . DIRECTORY_SEPARATOR);
defined('SYSTEMPATH') || define('SYSTEMPATH', realpath(rtrim($paths->systemDirectory, '\\/')) . DIRECTORY_SEPARATOR);
defined('WRITEPATH') || define('WRITEPATH', realpath(rtrim($paths->writableDirectory, '\\/ ')) . DIRECTORY_SEPARATOR);
defined('TESTPATH') || define('TESTPATH', realpath(HOMEPATH . 'tests/') . DIRECTORY_SEPARATOR);
defined('CIPATH') || define('CIPATH', realpath(SYSTEMPATH . '../') . DIRECTORY_SEPARATOR);
defined('FCPATH') || define('FCPATH', realpath(PUBLICPATH) . DIRECTORY_SEPARATOR);
defined('SUPPORTPATH') || define('SUPPORTPATH', realpath(TESTPATH . '_support/') . DIRECTORY_SEPARATOR);
defined('COMPOSER_PATH') || define('COMPOSER_PATH', (string) realpath(HOMEPATH . 'vendor/autoload.php'));
defined('VENDORPATH') || define('VENDORPATH', realpath(HOMEPATH . 'vendor') . DIRECTORY_SEPARATOR);
/*
*---------------------------------------------------------------
* BOOTSTRAP THE APPLICATION
*---------------------------------------------------------------
* This process sets up the path constants, loads and registers
* our autoloader, along with Composer's, loads our constants
* and fires up an environment-specific bootstrapping.
*/
// LOAD THE FRAMEWORK BOOTSTRAP FILE
require $paths->systemDirectory . '/Boot.php';
Boot::bootTest($paths);
/*
* ---------------------------------------------------------------
* LOAD ROUTES
* ---------------------------------------------------------------
*/
Services::routes()->loadRoutes();