first commit
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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) . '.',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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' => '',
|
||||
],
|
||||
];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 = '';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user