diff --git a/.gitea/workflows/tests.yml b/.gitea/workflows/tests.yml index 128bac6..7079e28 100644 --- a/.gitea/workflows/tests.yml +++ b/.gitea/workflows/tests.yml @@ -31,7 +31,12 @@ jobs: env: GIT_SSL_NO_VERIFY: 'true' CI_ENVIRONMENT: testing - database.tests.hostname: 127.0.0.1 + TEST_DB_HOST: mysql + TEST_DB_PORT: 3306 + TEST_DB_NAME: alrahma_test + TEST_DB_USER: alrahma + TEST_DB_PASSWORD: alrahma + database.tests.hostname: mysql database.tests.database: alrahma_test database.tests.username: alrahma database.tests.password: alrahma @@ -75,8 +80,92 @@ jobs: touch writable/cache/testing/.ci-write-test rm writable/cache/testing/.ci-write-test + - name: Verify database configuration + run: | + php -d variables_order=EGPCS -r ' + define("FCPATH", __DIR__ . "/public/"); + define("ENVIRONMENT", getenv("CI_ENVIRONMENT") ?: "production"); + require "app/Config/Paths.php"; + $paths = new Config\Paths(); + require $paths->systemDirectory . "/Boot.php"; + CodeIgniter\Boot::bootConsole($paths); + + $database = config("Database"); + $dbConfig = $database->{$database->defaultGroup}; + + echo "Environment: " . ENVIRONMENT . PHP_EOL; + echo "DB group: " . $database->defaultGroup . PHP_EOL; + echo "DB host: " . $dbConfig["hostname"] . PHP_EOL; + echo "DB port: " . $dbConfig["port"] . PHP_EOL; + echo "DB name: " . $dbConfig["database"] . PHP_EOL; + ' + + - name: Wait for MariaDB + run: | + for attempt in $(seq 1 30); do + php -r ' + mysqli_report(MYSQLI_REPORT_OFF); + $db = @new mysqli( + getenv("TEST_DB_HOST"), + getenv("TEST_DB_USER"), + getenv("TEST_DB_PASSWORD"), + getenv("TEST_DB_NAME"), + (int) getenv("TEST_DB_PORT") + ); + + exit($db->connect_errno === 0 ? 0 : 1); + ' && break + + echo "Waiting for MariaDB, attempt ${attempt}/30" + sleep 2 + done + + php -r ' + mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT); + + new mysqli( + getenv("TEST_DB_HOST"), + getenv("TEST_DB_USER"), + getenv("TEST_DB_PASSWORD"), + getenv("TEST_DB_NAME"), + (int) getenv("TEST_DB_PORT") + ); + + echo "MariaDB connection successful\n"; + ' + + - name: Import baseline schema + run: php tests/_support/import_baseline.php Database_Dump/u280815660_school_12072025 + - name: Run database migrations - run: php spark migrate --all --no-interaction + run: | + php -d variables_order=EGPCS spark migrate --all --no-interaction + php -r ' + mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT); + + $db = new mysqli( + getenv("TEST_DB_HOST"), + getenv("TEST_DB_USER"), + getenv("TEST_DB_PASSWORD"), + getenv("TEST_DB_NAME"), + (int) getenv("TEST_DB_PORT") + ); + + $tables = []; + $result = $db->query("SHOW TABLES"); + while ($row = $result->fetch_array(MYSQLI_NUM)) { + $tables[$row[0]] = true; + } + + foreach (["users", "students", "attendance_data"] as $table) { + if (!isset($tables[$table])) { + fwrite(STDERR, "Migration schema missing required table: {$table}\n"); + exit(1); + } + } + + echo "Migration schema verified\n"; + ' - name: Run tests run: composer test diff --git a/app/Config/Database.php b/app/Config/Database.php index 0af3e90..b08ced0 100644 --- a/app/Config/Database.php +++ b/app/Config/Database.php @@ -16,6 +16,10 @@ class Database extends Config { parent::__construct(); + if (ENVIRONMENT === 'testing') { + $this->defaultGroup = 'tests'; + } + $this->default = [ 'DSN' => '', 'hostname' => env('database.default.hostname'), @@ -38,22 +42,22 @@ class Database extends Config $this->tests = [ 'DSN' => '', - 'hostname' => env('database.tests.hostname', env('database.default.hostname')), - 'username' => env('database.tests.username', env('database.default.username')), - 'password' => env('database.tests.password', env('database.default.password')), - 'database' => env('database.tests.database', env('database.default.database')), + 'hostname' => env('TEST_DB_HOST', env('database.tests.hostname', env('database.default.hostname'))), + 'username' => env('TEST_DB_USER', env('database.tests.username', env('database.default.username'))), + 'password' => env('TEST_DB_PASSWORD', env('database.tests.password', env('database.default.password'))), + 'database' => env('TEST_DB_NAME', env('database.tests.database', env('database.default.database'))), 'DBDriver' => env('database.tests.DBDriver', env('database.default.DBDriver', 'MySQLi')), - 'DBPrefix' => env('database.tests.DBPrefix', 'db_'), + 'DBPrefix' => env('database.tests.DBPrefix', ''), 'pConnect' => false, 'DBDebug' => true, - 'charset' => 'utf8', - 'DBCollat' => 'utf8_general_ci', + 'charset' => 'utf8mb4', + 'DBCollat' => 'utf8mb4_general_ci', 'swapPre' => '', 'encrypt' => false, 'compress' => false, 'strictOn' => false, 'failover' => [], - 'port' => (int) env('database.tests.port', env('database.default.port', 3306)), + 'port' => (int) env('TEST_DB_PORT', env('database.tests.port', env('database.default.port', 3306))), ]; } } diff --git a/app/Database/Migrations/2026-01-05-215851_FixPrintRequestsForeignKey.php b/app/Database/Migrations/2026-01-05-215851_FixPrintRequestsForeignKey.php index c27e93c..44dbc30 100644 --- a/app/Database/Migrations/2026-01-05-215851_FixPrintRequestsForeignKey.php +++ b/app/Database/Migrations/2026-01-05-215851_FixPrintRequestsForeignKey.php @@ -11,6 +11,10 @@ class FixPrintRequestsForeignKey extends Migration // Drop the old foreign key if it exists $this->forge->dropForeignKey('print_requests', 'print_requests_class_id_foreign'); + if (! $this->db->tableExists('class_sections')) { + return; + } + // Add the new foreign key $this->forge->addForeignKey('class_id', 'class_sections', 'id', 'CASCADE', 'CASCADE'); diff --git a/app/Database/Migrations/2026-01-06-195508_RevertPrintRequestsForeignKey.php b/app/Database/Migrations/2026-01-06-195508_RevertPrintRequestsForeignKey.php index 9b0770f..fd73e11 100644 --- a/app/Database/Migrations/2026-01-06-195508_RevertPrintRequestsForeignKey.php +++ b/app/Database/Migrations/2026-01-06-195508_RevertPrintRequestsForeignKey.php @@ -9,7 +9,14 @@ class RevertPrintRequestsForeignKey extends Migration public function up() { // Drop the old foreign key if it exists - $this->forge->dropForeignKey('print_requests', 'print_requests_class_id_foreign'); + $db = \Config\Database::connect(); + $keys = $db->getForeignKeyData('print_requests'); + foreach ($keys as $key) { + if ($key->constraint_name === 'print_requests_class_id_foreign') { + $this->forge->dropForeignKey('print_requests', 'print_requests_class_id_foreign'); + break; + } + } // Add the new foreign key $this->forge->addForeignKey('class_id', 'classes', 'id', 'CASCADE', 'CASCADE'); diff --git a/app/Database/Migrations/2026-01-06-205621_FixPrintRequestsForeignKeyAgain.php b/app/Database/Migrations/2026-01-06-205621_FixPrintRequestsForeignKeyAgain.php index 41d223c..6469c2b 100644 --- a/app/Database/Migrations/2026-01-06-205621_FixPrintRequestsForeignKeyAgain.php +++ b/app/Database/Migrations/2026-01-06-205621_FixPrintRequestsForeignKeyAgain.php @@ -12,8 +12,7 @@ class FixPrintRequestsForeignKeyAgain extends Migration $keys = $db->getForeignKeyData('print_requests'); foreach ($keys as $key) { if ($key->constraint_name === 'print_requests_class_id_foreign') { - $this->forge->dropForeignKey('print_requests', 'print_requests_class_id_foreign'); - break; + return; } } diff --git a/app/Database/Migrations/2026-01-25-000000_RemoveClassAssignmentSemester.php b/app/Database/Migrations/2026-01-25-000000_RemoveClassAssignmentSemester.php index de8ff5b..9cc8fa3 100644 --- a/app/Database/Migrations/2026-01-25-000000_RemoveClassAssignmentSemester.php +++ b/app/Database/Migrations/2026-01-25-000000_RemoveClassAssignmentSemester.php @@ -8,10 +8,18 @@ class RemoveClassAssignmentSemester extends Migration { public function up() { - if ($this->db->tableExists('teacher_class')) { + if ($this->db->tableExists('teacher_class') && $this->db->fieldExists('semester', 'teacher_class')) { + if ($this->indexExists('teacher_class', 'unique_teacher_assignment')) { + $this->db->query('ALTER TABLE `teacher_class` DROP INDEX `unique_teacher_assignment`'); + } + $this->forge->dropColumn('teacher_class', 'semester'); + + $this->db->query( + 'ALTER TABLE `teacher_class` ADD UNIQUE KEY `unique_teacher_assignment` (`teacher_id`, `class_section_id`, `school_year`)' + ); } - if ($this->db->tableExists('student_class')) { + if ($this->db->tableExists('student_class') && $this->db->fieldExists('semester', 'student_class')) { $this->forge->dropColumn('student_class', 'semester'); } } @@ -39,4 +47,13 @@ class RemoveClassAssignmentSemester extends Migration ]); } } + + private function indexExists(string $table, string $index): bool + { + $result = $this->db->query( + 'SHOW INDEX FROM `' . str_replace('`', '``', $table) . '` WHERE Key_name = ' . $this->db->escape($index) + ); + + return $result->getNumRows() > 0; + } } diff --git a/app/Database/Migrations/2026-01-26-000100_CreateTeacherSubmissionNotificationHistory.php b/app/Database/Migrations/2026-01-26-000100_CreateTeacherSubmissionNotificationHistory.php index 0b4faa1..eef2e87 100644 --- a/app/Database/Migrations/2026-01-26-000100_CreateTeacherSubmissionNotificationHistory.php +++ b/app/Database/Migrations/2026-01-26-000100_CreateTeacherSubmissionNotificationHistory.php @@ -3,6 +3,7 @@ namespace App\Database\Migrations; use CodeIgniter\Database\Migration; +use CodeIgniter\Database\RawSql; class CreateTeacherSubmissionNotificationHistory extends Migration { @@ -56,7 +57,7 @@ class CreateTeacherSubmissionNotificationHistory extends Migration 'sent_at' => [ 'type' => 'DATETIME', 'null' => false, - 'default' => 'CURRENT_TIMESTAMP', + 'default' => new RawSql('CURRENT_TIMESTAMP'), ], ]); diff --git a/app/Database/Migrations/2026-01-27-130500_CreateExamDraftSubmissions.php b/app/Database/Migrations/2026-01-27-130500_CreateExamDraftSubmissions.php index 700d5b0..9039c57 100644 --- a/app/Database/Migrations/2026-01-27-130500_CreateExamDraftSubmissions.php +++ b/app/Database/Migrations/2026-01-27-130500_CreateExamDraftSubmissions.php @@ -3,6 +3,7 @@ namespace App\Database\Migrations; use CodeIgniter\Database\Migration; +use CodeIgniter\Database\RawSql; class CreateExamDraftSubmissions extends Migration { @@ -89,7 +90,7 @@ class CreateExamDraftSubmissions extends Migration 'created_at' => [ 'type' => 'DATETIME', 'null' => false, - 'default' => 'CURRENT_TIMESTAMP', + 'default' => new RawSql('CURRENT_TIMESTAMP'), ], 'updated_at' => [ 'type' => 'DATETIME', diff --git a/app/Database/Migrations/2026-02-04-000400_CreatePlacementLevels.php b/app/Database/Migrations/2026-02-04-000400_CreatePlacementLevels.php index a04d3f2..00ab54c 100644 --- a/app/Database/Migrations/2026-02-04-000400_CreatePlacementLevels.php +++ b/app/Database/Migrations/2026-02-04-000400_CreatePlacementLevels.php @@ -24,7 +24,7 @@ class CreatePlacementLevels extends Migration ]); $this->forge->addKey('id', true); - $this->forge->addKey(['student_id', 'school_year'], true); + $this->forge->addUniqueKey(['student_id', 'school_year'], 'unique_student_school_year'); $this->forge->addKey('school_year'); $this->forge->createTable('placement_levels'); } diff --git a/app/Database/Migrations/2026-02-04-000430_CreatePlacementScores.php b/app/Database/Migrations/2026-02-04-000430_CreatePlacementScores.php index 50cf99a..6a63351 100644 --- a/app/Database/Migrations/2026-02-04-000430_CreatePlacementScores.php +++ b/app/Database/Migrations/2026-02-04-000430_CreatePlacementScores.php @@ -24,7 +24,7 @@ class CreatePlacementScores extends Migration ]); $this->forge->addKey('id', true); - $this->forge->addKey(['batch_id', 'student_id'], true); + $this->forge->addUniqueKey(['batch_id', 'student_id'], 'unique_batch_student'); $this->forge->addKey('batch_id'); $this->forge->createTable('placement_scores'); } diff --git a/app/Database/Migrations/2026-02-05-000200_CreateMissingScoreOverrides.php b/app/Database/Migrations/2026-02-05-000200_CreateMissingScoreOverrides.php index d5b6ede..f75f729 100644 --- a/app/Database/Migrations/2026-02-05-000200_CreateMissingScoreOverrides.php +++ b/app/Database/Migrations/2026-02-05-000200_CreateMissingScoreOverrides.php @@ -65,7 +65,10 @@ class CreateMissingScoreOverrides extends Migration $this->forge->addKey('id', true); $this->forge->addKey(['class_section_id', 'semester', 'school_year', 'item_type'], false, 'missing_score_overrides_scope'); - $this->forge->addKey(['student_id', 'class_section_id', 'semester', 'school_year', 'item_type', 'item_index'], true, 'missing_score_overrides_unique'); + $this->forge->addUniqueKey( + ['student_id', 'class_section_id', 'semester', 'school_year', 'item_type', 'item_index'], + 'missing_score_overrides_unique' + ); $this->forge->createTable('missing_score_overrides', true); } diff --git a/app/Database/Migrations/2026-02-10-000100_AddStyleToPreferences.php b/app/Database/Migrations/2026-02-10-000100_AddStyleToPreferences.php index be8f9bd..62bf605 100644 --- a/app/Database/Migrations/2026-02-10-000100_AddStyleToPreferences.php +++ b/app/Database/Migrations/2026-02-10-000100_AddStyleToPreferences.php @@ -10,13 +10,18 @@ class AddStyleToPreferences extends Migration { if ($this->db->tableExists('user_preferences')) { if (! $this->db->fieldExists('style_color', 'user_preferences')) { + $styleColor = [ + 'type' => 'VARCHAR', + 'constraint' => 32, + 'null' => true, + ]; + + if ($this->db->fieldExists('timezone', 'user_preferences')) { + $styleColor['after'] = 'timezone'; + } + $this->forge->addColumn('user_preferences', [ - 'style_color' => [ - 'type' => 'VARCHAR', - 'constraint' => 32, - 'null' => true, - 'after' => 'timezone', - ], + 'style_color' => $styleColor, ]); } if (! $this->db->fieldExists('menu_color', 'user_preferences')) { diff --git a/app/Database/Migrations/2026-07-12-040600_FixSchoolYearColumns.php b/app/Database/Migrations/2026-07-12-040600_FixSchoolYearColumns.php index 95792e6..c7e47dd 100644 --- a/app/Database/Migrations/2026-07-12-040600_FixSchoolYearColumns.php +++ b/app/Database/Migrations/2026-07-12-040600_FixSchoolYearColumns.php @@ -122,6 +122,8 @@ class FixSchoolYearColumns extends Migration ); } + $this->db->resetDataCache(); + // These annual entities lacked a school_year column in the audited schema. foreach (['class_progress_reports', 'exams', 'print_requests'] as $table) { if ($this->db->tableExists($table) && ! $this->db->fieldExists('school_year', $table)) { @@ -137,6 +139,8 @@ class FixSchoolYearColumns extends Migration } } + $this->db->resetDataCache(); + // Validate and normalize every direct owner to VARCHAR(9). foreach ($this->yearOwnerTables as $table) { if (! $this->db->tableExists($table) || ! $this->db->fieldExists('school_year', $table)) { @@ -163,6 +167,8 @@ class FixSchoolYearColumns extends Migration } } + $this->db->resetDataCache(); + // Remove redundant columns. Dependent non-primary indexes are removed first. foreach ($this->tablesWithoutDirectYear as $table) { if (! $this->db->tableExists($table) || ! $this->db->fieldExists('school_year', $table)) { @@ -172,6 +178,7 @@ class FixSchoolYearColumns extends Migration $this->dropIndexesContainingColumn($table, 'school_year'); $this->dropChecksContainingColumn($table, 'school_year'); $this->forge->dropColumn($table, 'school_year'); + $this->db->resetDataCache(); } foreach ($this->tablesWithoutDirectSemester as $table) { @@ -182,8 +189,11 @@ class FixSchoolYearColumns extends Migration $this->dropIndexesContainingColumn($table, 'semester'); $this->dropChecksContainingColumn($table, 'semester'); $this->forge->dropColumn($table, 'semester'); + $this->db->resetDataCache(); } + $this->db->resetDataCache(); + foreach ($this->yearOwnerTables as $table) { if (! $this->db->tableExists($table) || ! $this->db->fieldExists('school_year', $table)) { continue; diff --git a/composer.json b/composer.json index d108f61..73a9914 100644 --- a/composer.json +++ b/composer.json @@ -49,6 +49,6 @@ "sort-packages": true }, "scripts": { - "test": "phpunit" + "test": "php -d variables_order=EGPCS vendor/bin/phpunit" } } diff --git a/tests/_support/DBReset.php b/tests/_support/DBReset.php index b285dc7..0a971f8 100644 --- a/tests/_support/DBReset.php +++ b/tests/_support/DBReset.php @@ -2,19 +2,39 @@ namespace Tests\Support; -use CodeIgniter\Test\CIUnitTestCase; use Config\Database; +use Throwable; class DBReset { + private const DB_GROUP = 'tests'; + protected static array $excludeTables = ['migrations']; public static function resetDatabase(): void { - $db = Database::connect(); + $config = config('Database'); + $dbConfig = $config->{self::DB_GROUP} ?? []; + + try { + $db = Database::connect(self::DB_GROUP); + $db->initialize(); + } catch (Throwable $exception) { + throw new \RuntimeException( + sprintf( + 'Test database connection failed: group=%s host=%s port=%s database=%s', + self::DB_GROUP, + $dbConfig['hostname'] ?? '', + $dbConfig['port'] ?? '', + $dbConfig['database'] ?? '' + ), + 0, + $exception + ); + } + $db->disableForeignKeyChecks(); - // Get all table names $tables = $db->listTables(); foreach ($tables as $table) { diff --git a/tests/_support/import_baseline.php b/tests/_support/import_baseline.php new file mode 100644 index 0000000..a49c39e --- /dev/null +++ b/tests/_support/import_baseline.php @@ -0,0 +1,126 @@ +set_charset('utf8mb4'); +$db->query('SET FOREIGN_KEY_CHECKS=0'); + +$tables = $db->query('SHOW FULL TABLES WHERE Table_type = "BASE TABLE"')->fetch_all(MYSQLI_NUM); + +foreach ($tables as [$table]) { + $db->query('DROP TABLE IF EXISTS `' . str_replace('`', '``', $table) . '`'); +} + +$db->query('SET FOREIGN_KEY_CHECKS=1'); + +$sql = file_get_contents($dumpPath); + +if ($sql === false) { + fwrite(STDERR, "Unable to read baseline dump: {$dumpPath}\n"); + exit(1); +} + +$db->multi_query($sql); + +do { + if ($result = $db->store_result()) { + $result->free(); + } +} while ($db->more_results() && $db->next_result()); + +$baselineVersion = '2025-12-07-235959'; +$migrationFiles = glob(dirname(__DIR__, 2) . '/app/Database/Migrations/*.php') ?: []; +$maxId = (int) ($db->query('SELECT COALESCE(MAX(id), 0) AS id FROM migrations')->fetch_assoc()['id'] ?? 0); +$maxBatch = (int) ($db->query('SELECT COALESCE(MAX(batch), 0) AS batch FROM migrations')->fetch_assoc()['batch'] ?? 0); + +foreach ($migrationFiles as $file) { + $filename = basename($file, '.php'); + + if (! preg_match('/^(\d{4}-\d{2}-\d{2}-\d{6})_(.+)$/', $filename, $matches)) { + continue; + } + + [$all, $version] = $matches; + + if ($version > $baselineVersion) { + continue; + } + + $contents = file_get_contents($file); + + if ($contents === false || ! preg_match('/class\s+([A-Za-z_][A-Za-z0-9_]*)\s+extends\s+Migration/', $contents, $classMatch)) { + continue; + } + + $class = 'App\\Database\\Migrations\\' . $classMatch[1]; + $exists = $db + ->query( + 'SELECT 1 FROM migrations WHERE version = "' . $db->real_escape_string($version) . '"' + . ' AND class = "' . $db->real_escape_string($class) . '" LIMIT 1' + ) + ->num_rows > 0; + + if ($exists) { + continue; + } + + $date = DateTimeImmutable::createFromFormat('Y-m-d-His', $version); + $time = $date instanceof DateTimeImmutable ? $date->getTimestamp() : time(); + + $statement = $db->prepare( + 'INSERT INTO migrations (id, version, class, `group`, namespace, time, batch) VALUES (?, ?, ?, ?, ?, ?, ?)' + ); + + $id = ++$maxId; + $group = 'default'; + $namespace = 'App'; + $batch = $maxBatch; + $statement->bind_param('issssii', $id, $version, $class, $group, $namespace, $time, $batch); + $statement->execute(); +} + +$count = $db->query('SHOW TABLES')->num_rows; + +if ($count === 0) { + fwrite(STDERR, "Baseline import produced no tables\n"); + exit(1); +} + +echo "Baseline schema imported: {$count} table(s)\n"; diff --git a/tests/app/Models/AttendanceDataModelTest.php b/tests/app/Models/AttendanceDataModelTest.php index e3d4d02..24f8c09 100644 --- a/tests/app/Models/AttendanceDataModelTest.php +++ b/tests/app/Models/AttendanceDataModelTest.php @@ -16,25 +16,48 @@ class AttendanceDataModelTest extends CIUnitTestCase public function testCanInsertAndRetrieve() { - $record = fake(AttendanceDataModel::class); - $this->assertNotNull($record->id); - $fetched = (new AttendanceDataModel())->find($record->id); - $this->assertEquals($record->id, $fetched->id); + $model = new AttendanceDataModel(); + $id = $model->insert($this->validAttendanceRow()); + + $this->assertNotFalse($id); + + $fetched = $model->find($id); + $this->assertSame((int) $id, (int) $fetched['id']); } public function testCanUpdate() { - $record = fake(AttendanceDataModel::class); $model = new AttendanceDataModel(); - $model->update($record->id, ['updated_at' => date('Y-m-d H:i:s')]); - $this->assertTrue(true); // simple test to verify no exception thrown + $id = $model->insert($this->validAttendanceRow()); + + $this->assertTrue($model->update($id, ['updated_at' => date('Y-m-d H:i:s')])); } public function testCanDelete() { - $record = fake(AttendanceDataModel::class); $model = new AttendanceDataModel(); - $model->delete($record->id); - $this->assertNull($model->find($record->id)); + $id = $model->insert($this->validAttendanceRow()); + + $model->delete($id); + + $this->assertNull($model->find($id)); + } + + private function validAttendanceRow(): array + { + return [ + 'class_id' => 1, + 'class_section_id' => 1, + 'school_id' => 1, + 'student_id' => 1, + 'date' => '2026-01-01', + 'status' => 'present', + 'reason' => '', + 'is_reported' => 'no', + 'is_notified' => 'no', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'modified_by' => 1, + ]; } }