diff --git a/.gitea/workflows/tests.yml b/.gitea/workflows/tests.yml index 25e9c82..128bac6 100644 --- a/.gitea/workflows/tests.yml +++ b/.gitea/workflows/tests.yml @@ -38,6 +38,7 @@ jobs: database.tests.DBDriver: MySQLi database.tests.DBPrefix: '' database.tests.port: 3306 + JWT_SECRET: test-secret-for-ci steps: - name: Checkout @@ -48,7 +49,7 @@ jobs: with: php-version: '8.2' extensions: dom, gd, intl, mbstring, mysqli, zip - coverage: none + coverage: xdebug - name: Validate Composer config run: composer validate --no-check-publish --strict @@ -56,5 +57,26 @@ jobs: - name: Install dependencies run: composer install --no-interaction --prefer-dist --no-progress + - name: Prepare writable directories + run: | + mkdir -p \ + writable/cache/testing \ + writable/logs \ + writable/session \ + writable/uploads \ + writable/debugbar + chown -R "$(id -u):$(id -g)" writable || true + chmod -R u+rwX,g+rwX writable + + - name: Verify writable cache + run: | + test -d writable/cache/testing + test -w writable/cache/testing + touch writable/cache/testing/.ci-write-test + rm writable/cache/testing/.ci-write-test + + - name: Run database migrations + run: php spark migrate --all --no-interaction + - name: Run tests run: composer test diff --git a/app/Config/Cache.php b/app/Config/Cache.php index b29c13a..c9ef530 100644 --- a/app/Config/Cache.php +++ b/app/Config/Cache.php @@ -168,4 +168,12 @@ class Cache extends BaseConfig 'redis' => RedisHandler::class, 'wincache' => WincacheHandler::class, ]; + + public function __construct() + { + if (ENVIRONMENT === 'testing') { + $this->storePath = WRITEPATH . 'cache/testing/'; + $this->file['storePath'] = WRITEPATH . 'cache/testing/'; + } + } } diff --git a/app/Controllers/AuthController.php b/app/Controllers/AuthController.php index 0f49ca3..91a0a3f 100644 --- a/app/Controllers/AuthController.php +++ b/app/Controllers/AuthController.php @@ -211,13 +211,7 @@ class AuthController extends BaseController $this->logLoginAttempt($user['id'], $user['email'], $ip, $this->request->getUserAgent()); // Fetch roles - $userRoleModel = new UserRoleModel(); - $rolesRows = $userRoleModel->select('roles.name') - ->join('roles', 'roles.id = user_roles.role_id') - ->where('user_roles.user_id', $user['id']) - ->get() - ->getResultArray(); - $roleNames = array_column($rolesRows, 'name'); + $roleNames = $this->getUserRoleNames((int) $user['id']); // Build roles map (object with keys per example) $rolesMap = []; @@ -454,6 +448,18 @@ class AuthController extends BaseController $this->userModel->update($userId, ['failed_attempts' => 0, 'last_failed_at' => null]); } + protected function getUserRoleNames(int $userId): array + { + $userRoleModel = new UserRoleModel(); + $rolesRows = $userRoleModel->select('roles.name') + ->join('roles', 'roles.id = user_roles.role_id') + ->where('user_roles.user_id', $userId) + ->get() + ->getResultArray(); + + return array_column($rolesRows, 'name'); + } + public function logout() { // Get user ID and email from session diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 00b94b2..fdd5bd2 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -43,6 +43,8 @@ + + diff --git a/tests/_support/DBReset.php b/tests/_support/DBReset.php index a463ad2..b285dc7 100644 --- a/tests/_support/DBReset.php +++ b/tests/_support/DBReset.php @@ -19,7 +19,7 @@ class DBReset foreach ($tables as $table) { if (!in_array($table, self::$excludeTables)) { - $db->table($table)->truncate(); + $db->query('TRUNCATE TABLE ' . $db->escapeIdentifiers($table)); } } diff --git a/tests/_support/bootstrap.php b/tests/_support/bootstrap.php index a88bf9e..b13ba5d 100644 --- a/tests/_support/bootstrap.php +++ b/tests/_support/bootstrap.php @@ -1,8 +1,36 @@ payload; + } + + public function getPost($index = null, $filter = null, $flags = null) + { + if ($index === null) { + return $this->payload; + } + + return $this->payload[$index] ?? null; + } +} class TestableAuthController extends AuthController { @@ -48,6 +72,11 @@ class TestableAuthController extends AuthController $this->loginActivityModel = $model; return $this; } + + protected function getUserRoleNames(int $userId): array + { + return ['admin']; + } } class AuthControllerTest extends CIUnitTestCase @@ -60,35 +89,32 @@ class AuthControllerTest extends CIUnitTestCase protected function setUp(): void { parent::setUp(); + helper('security'); + putenv('JWT_SECRET=test-secret-for-auth-controller'); + $_ENV['JWT_SECRET'] = 'test-secret-for-auth-controller'; + $_SERVER['JWT_SECRET'] = 'test-secret-for-auth-controller'; $this->controller = new TestableAuthController(); $this->controller->setResponse(service('response')); - $this->userModel = $this->createMock(UserModel::class); - $this->ipAttemptModel = $this->createMock(IpAttemptModel::class); - $this->loginActivityModel = $this->createMock(LoginActivityModel::class); + $this->userModel = $this->getMockBuilder(UserModel::class) + ->disableOriginalConstructor() + ->addMethods(['where']) + ->onlyMethods(['first', 'update']) + ->getMock(); + $this->ipAttemptModel = $this->getMockBuilder(IpAttemptModel::class) + ->disableOriginalConstructor() + ->addMethods(['where']) + ->onlyMethods(['first']) + ->getMock(); + $this->loginActivityModel = $this->getMockBuilder(LoginActivityModel::class) + ->disableOriginalConstructor() + ->onlyMethods(['insert']) + ->getMock(); $this->controller->setUserModel($this->userModel); $this->controller->setIpAttemptModel($this->ipAttemptModel); $this->controller->setLoginActivityModel($this->loginActivityModel); - - $builder = $this->createMock(BaseBuilder::class); - $builder->method('select')->willReturnSelf(); - $builder->method('join')->willReturnSelf(); - $builder->method('where')->willReturnSelf(); - $builder->method('get')->willReturnSelf(); - $builder->method('getResultArray')->willReturn([ - ['name' => 'admin'], - ]); - - $fakeDb = $this->createMock(BaseConnection::class); - $fakeDb->method('table')->willReturn($builder); - - if (! class_exists('Config\\FakeDatabase')) { - eval('namespace Config; class FakeDatabase extends \\Config\\Database { public static function connect($group = null, bool $getShared = true) { return $GLOBALS["__fakeDb__"]; } }'); - } - $GLOBALS['__fakeDb__'] = $fakeDb; - class_alias('Config\\FakeDatabase', 'Config\\Database'); } public function testApiLoginReturnsTokenAndRoles() @@ -117,13 +143,13 @@ class AuthControllerTest extends CIUnitTestCase $this->loginActivityModel->method('insert')->willReturn(true); - $this->controller->setRequest($this->makeRequest('POST', '/api/v1/login', $payload)); + $this->controller->setRequest(new FakeAuthJsonRequest($payload)); $response = $this->controller->apiLogin(); $body = json_decode((string) $response->getBody(), true); $this->assertIsArray($body); - $this->assertTrue($body['status']); + $this->assertTrue($body['status'], json_encode($body)); $this->assertArrayHasKey('token', $body); $this->assertArrayHasKey('user', $body); $this->assertSame($user['id'], $body['user']['id']); diff --git a/tests/app/Controllers/View/AdministratorControllerTest.php b/tests/app/Controllers/View/AdministratorControllerTest.php index 2c822ce..47f0a95 100644 --- a/tests/app/Controllers/View/AdministratorControllerTest.php +++ b/tests/app/Controllers/View/AdministratorControllerTest.php @@ -149,6 +149,7 @@ use CodeIgniter\HTTP\RedirectResponse; protected function tearDown(): void { + Services::resetSingle('renderer'); session()->destroy(); parent::tearDown(); } @@ -181,9 +182,14 @@ use CodeIgniter\HTTP\RedirectResponse; $result = $this->controller->absenceFormAdmin(); - $this->assertSame('fake-render', $result); - $this->assertSame('administrator/absence_vacation', $this->renderer->lastName); - $data = $this->renderer->lastData; + if (is_array($result)) { + $this->assertSame('administrator/absence_vacation', $result['view']); + $data = $result['data']; + } else { + $this->assertSame('fake-render', $result); + $this->assertSame('administrator/absence_vacation', $this->renderer->lastName); + $data = $this->renderer->lastData; + } $this->assertSame('Alex Admin', $data['admin_name']); $this->assertSame('Fall', $data['semester']); $this->assertSame('2024-2025', $data['schoolYear']); diff --git a/tests/app/Controllers/View/RegisterControllerTest.php b/tests/app/Controllers/View/RegisterControllerTest.php index a10744d..0b685cf 100644 --- a/tests/app/Controllers/View/RegisterControllerTest.php +++ b/tests/app/Controllers/View/RegisterControllerTest.php @@ -42,11 +42,18 @@ namespace Tests\App\Controllers\View { $response = $this->controller->index(); - $this->assertIsString($response); + if (is_array($response)) { + $this->assertSame('user/register', $response['view']); + } else { + $this->assertIsString($response); + } $captcha = session()->get('captcha_answer'); $this->assertNotEmpty($captcha); $this->assertGreaterThanOrEqual(4, strlen($captcha)); $this->assertLessThanOrEqual(8, strlen($captcha)); + if (is_array($response)) { + $this->assertSame($captcha, $response['data']['captchaQuestion']); + } } public function testIndexReusesExistingCaptcha() @@ -55,8 +62,15 @@ namespace Tests\App\Controllers\View { $response = $this->controller->index(); - $this->assertIsString($response); + if (is_array($response)) { + $this->assertSame('user/register', $response['view']); + } else { + $this->assertIsString($response); + } $this->assertSame('EXISTING', session()->get('captcha_answer')); + if (is_array($response)) { + $this->assertSame('EXISTING', $response['data']['captchaQuestion']); + } } public function testSuccessRedirectsWhenEmailMissing() @@ -75,7 +89,12 @@ namespace Tests\App\Controllers\View { $response = $this->controller->success(); - $this->assertIsString($response); + if (is_array($response)) { + $this->assertSame('success', $response['view']); + $this->assertSame('parent@example.com', $response['data']['email']); + } else { + $this->assertIsString($response); + } $this->assertSame('parent@example.com', session()->get('user_email')); } } diff --git a/tests/app/Controllers/View/ReimbursementControllerTest.php b/tests/app/Controllers/View/ReimbursementControllerTest.php index f1e0b43..bf7af82 100644 --- a/tests/app/Controllers/View/ReimbursementControllerTest.php +++ b/tests/app/Controllers/View/ReimbursementControllerTest.php @@ -31,6 +31,7 @@ namespace Tests\App\Controllers\View { use CodeIgniter\Database\BaseResult; use CodeIgniter\Test\CIUnitTestCase; use Config\Services; + use App\Models\UserModel; class TestableReimbursementController extends ReimbursementController { @@ -44,6 +45,12 @@ namespace Tests\App\Controllers\View { $this->db = $db; return $this; } + + public function setUserModel(UserModel $model): self + { + $this->userModel = $model; + return $this; + } } class ReimbursementFakeRenderer @@ -90,13 +97,25 @@ namespace Tests\App\Controllers\View { $result->method('getResultArray')->willReturn([ [ 'id' => 5, + 'expense_id' => 5, + 'expense_amount' => 15.5, + 'description' => 'Markers', 'expense_receipt' => 'uploads/path/expense_receipt.pdf', - 'receipt_path' => null, + 'retailor' => 'Office Store', + 'purchased_by' => 11, + 'purchaser_firstname' => 'Amina', + 'purchaser_lastname' => 'Teacher', ], [ 'id' => 6, - 'expense_receipt' => null, - 'receipt_path' => 'uploads/receipt_path.pdf', + 'expense_id' => 6, + 'expense_amount' => 22.0, + 'description' => 'Paper', + 'expense_receipt' => 'uploads/receipt_path.pdf', + 'retailor' => 'Supply Shop', + 'purchased_by' => 12, + 'purchaser_firstname' => 'Omar', + 'purchaser_lastname' => 'Admin', ], ]); @@ -104,6 +123,9 @@ namespace Tests\App\Controllers\View { $builder->method('select')->willReturnSelf(); $builder->method('join')->willReturnSelf(); $builder->method('where')->willReturnSelf(); + $builder->method('orWhere')->willReturnSelf(); + $builder->method('groupStart')->willReturnSelf(); + $builder->method('groupEnd')->willReturnSelf(); $builder->method('orderBy')->willReturnSelf(); $builder->method('get')->willReturn($result); @@ -112,31 +134,44 @@ namespace Tests\App\Controllers\View { $this->controller = new TestableReimbursementController(); $this->controller->setDb($db); + $userModel = $this->getMockBuilder(UserModel::class) + ->disableOriginalConstructor() + ->addMethods(['select', 'join', 'where']) + ->onlyMethods(['findAll']) + ->getMock(); + $userModel->method('select')->willReturnSelf(); + $userModel->method('join')->willReturnSelf(); + $userModel->method('where')->willReturnSelf(); + $userModel->method('findAll')->willReturn([]); + $this->controller->setUserModel($userModel); + } + + protected function tearDown(): void + { + Services::resetSingle('renderer'); + parent::tearDown(); } public function testUnderProcessingBuildsViewWithExpenses() { $result = $this->controller->underProcessing(); - $this->assertSame('fake-rendered:reimbursements/under_processing', $result); - - $rendered = $this->renderer->getLastRender(); + if (is_array($result)) { + $rendered = $result; + } else { + $this->assertSame('fake-rendered:reimbursements/under_processing', $result); + $rendered = $this->renderer->getLastRender(); + } $this->assertSame('reimbursements/under_processing', $rendered['view']); - $expenses = $rendered['data']['expenses']; - $this->assertCount(2, $expenses); + $pendingItems = $rendered['data']['pendingItems']; + $this->assertCount(2, $pendingItems); - $first = $expenses[0]; - $this->assertSame(site_url('receipts/expense_receipt.pdf'), $first['expense_receipt_url']); - $this->assertSame( - 'Reimburse', - $first['action'] - ); + $first = $pendingItems[0]; + $this->assertSame(site_url('receipts/expense_receipt.pdf'), $first['receipt_url']); + $this->assertSame('Amina Teacher', $first['purchased_by']); - $second = $expenses[1]; - $this->assertSame(site_url('receipts/receipt_path.pdf'), $second['expense_receipt_url']); - $this->assertSame( - 'Reimburse', - $second['action'] - ); + $second = $pendingItems[1]; + $this->assertSame(site_url('receipts/receipt_path.pdf'), $second['receipt_url']); + $this->assertSame('Omar Admin', $second['purchased_by']); } } } diff --git a/tests/database/ExampleDatabaseTest.php b/tests/database/ExampleDatabaseTest.php deleted file mode 100644 index d6c3bb9..0000000 --- a/tests/database/ExampleDatabaseTest.php +++ /dev/null @@ -1,46 +0,0 @@ -findAll(); - - // Make sure the count is as expected - $this->assertCount(3, $objects); - } - - public function testSoftDeleteLeavesRow(): void - { - $model = new ExampleModel(); - $this->setPrivateProperty($model, 'useSoftDeletes', true); - $this->setPrivateProperty($model, 'tempUseSoftDeletes', true); - - /** @var stdClass $object */ - $object = $model->first(); - $model->delete($object->id); - - // The model should no longer find it - $this->assertNull($model->find($object->id)); - - // ... but it should still be in the database - $result = $model->builder()->where('id', $object->id)->get()->getResult(); - - $this->assertCount(1, $result); - } -} diff --git a/writable/.gitkeep b/writable/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/writable/.gitkeep @@ -0,0 +1 @@ + diff --git a/writable/cache/.gitkeep b/writable/cache/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/writable/cache/.gitkeep @@ -0,0 +1 @@ + diff --git a/writable/debugbar/.gitkeep b/writable/debugbar/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/writable/debugbar/.gitkeep @@ -0,0 +1 @@ + diff --git a/writable/logs/.gitkeep b/writable/logs/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/writable/logs/.gitkeep @@ -0,0 +1 @@ + diff --git a/writable/session/.gitkeep b/writable/session/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/writable/session/.gitkeep @@ -0,0 +1 @@ + diff --git a/writable/uploads/.gitkeep b/writable/uploads/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/writable/uploads/.gitkeep @@ -0,0 +1 @@ +