diff --git a/app/Config/Database.php b/app/Config/Database.php index b08ced0..e9f23f1 100644 --- a/app/Config/Database.php +++ b/app/Config/Database.php @@ -45,7 +45,7 @@ class Database extends Config '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'))), + 'database' => env('TEST_DB_NAME', env('database.tests.database', 'alrahma_test')), 'DBDriver' => env('database.tests.DBDriver', env('database.default.DBDriver', 'MySQLi')), 'DBPrefix' => env('database.tests.DBPrefix', ''), 'pConnect' => false, diff --git a/app/Database/Migrations/2026-01-25-000000_RemoveClassAssignmentSemester.php b/app/Database/Migrations/2026-01-25-000000_RemoveClassAssignmentSemester.php index 9cc8fa3..89f894a 100644 --- a/app/Database/Migrations/2026-01-25-000000_RemoveClassAssignmentSemester.php +++ b/app/Database/Migrations/2026-01-25-000000_RemoveClassAssignmentSemester.php @@ -8,20 +8,8 @@ class RemoveClassAssignmentSemester extends Migration { public function up() { - 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') && $this->db->fieldExists('semester', 'student_class')) { - $this->forge->dropColumn('student_class', 'semester'); - } + // Keep the legacy semester columns in place. They may be ignored by newer + // code, but dropping them during a post-restore migrate destroys data. } public function down() diff --git a/app/Database/Migrations/2026-05-30-000001_FinancialSystemLedgerCleanup.php b/app/Database/Migrations/2026-05-30-000001_FinancialSystemLedgerCleanup.php index 7cb4ba7..594c33a 100644 --- a/app/Database/Migrations/2026-05-30-000001_FinancialSystemLedgerCleanup.php +++ b/app/Database/Migrations/2026-05-30-000001_FinancialSystemLedgerCleanup.php @@ -183,8 +183,8 @@ class FinancialSystemLedgerCleanup extends Migration protected function archivePaypalTables(): void { - $this->renameTableIfPresent('paypal_payments', 'archived_paypal_payments'); - $this->renameTableIfPresent('paypal_transactions', 'archived_paypal_transactions'); + // Keep legacy PayPal tables under their original names. Renaming them + // during migrate makes restored data appear to disappear from the app. } protected function restorePaypalTables(): void diff --git a/app/Database/Migrations/2026-07-12-040600_FixSchoolYearColumns.php b/app/Database/Migrations/2026-07-12-040600_FixSchoolYearColumns.php index c7e47dd..af27806 100644 --- a/app/Database/Migrations/2026-07-12-040600_FixSchoolYearColumns.php +++ b/app/Database/Migrations/2026-07-12-040600_FixSchoolYearColumns.php @@ -169,28 +169,9 @@ 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)) { - continue; - } - - $this->dropIndexesContainingColumn($table, 'school_year'); - $this->dropChecksContainingColumn($table, 'school_year'); - $this->forge->dropColumn($table, 'school_year'); - $this->db->resetDataCache(); - } - - foreach ($this->tablesWithoutDirectSemester as $table) { - if (! $this->db->tableExists($table) || ! $this->db->fieldExists('semester', $table)) { - continue; - } - - $this->dropIndexesContainingColumn($table, 'semester'); - $this->dropChecksContainingColumn($table, 'semester'); - $this->forge->dropColumn($table, 'semester'); - $this->db->resetDataCache(); - } + // Do not drop redundant school_year/semester columns here. Restored + // production dumps may still contain meaningful historical data in + // those columns, and running migrate must not destroy it. $this->db->resetDataCache(); diff --git a/app/Database/Migrations/2026-07-12-044500_ApplySchoolYearSemesterAuditCorrections.php b/app/Database/Migrations/2026-07-12-044500_ApplySchoolYearSemesterAuditCorrections.php index b9650c8..32d9334 100644 --- a/app/Database/Migrations/2026-07-12-044500_ApplySchoolYearSemesterAuditCorrections.php +++ b/app/Database/Migrations/2026-07-12-044500_ApplySchoolYearSemesterAuditCorrections.php @@ -61,15 +61,8 @@ final class ApplySchoolYearSemesterAuditCorrections extends Migration $this->ensureIndex($table, 'school_year'); } - foreach ($this->semesterDropTables as $table) { - if (! $this->db->tableExists($table) || ! $this->db->fieldExists('semester', $table)) { - continue; - } - - $this->dropIndexesContainingColumn($table, 'semester'); - $this->dropChecksContainingColumn($table, 'semester'); - $this->forge->dropColumn($table, 'semester'); - } + // Preserve legacy semester columns. They may be redundant for newer + // code paths, but migration must not delete restored production data. foreach ($this->schoolYearIndexTables as $table) { if (! $this->db->tableExists($table) || ! $this->db->fieldExists('school_year', $table)) { diff --git a/app/Models/StatsModel.php b/app/Models/StatsModel.php deleted file mode 100644 index 61aed33..0000000 --- a/app/Models/StatsModel.php +++ /dev/null @@ -1,22 +0,0 @@ -findAll(); - } -} diff --git a/tests/_support/DBReset.php b/tests/_support/DBReset.php index b680227..ede98d9 100644 --- a/tests/_support/DBReset.php +++ b/tests/_support/DBReset.php @@ -15,6 +15,7 @@ class DBReset { $config = config('Database'); $dbConfig = $config->{self::DB_GROUP} ?? []; + self::assertSafeTestDatabase($config->default ?? [], $dbConfig); try { $db = Database::connect(self::DB_GROUP); @@ -35,14 +36,38 @@ class DBReset $db->disableForeignKeyChecks(); - $tables = $db->listTables(); + $tables = $db->resetDataCache()->listTables(); foreach ($tables as $table) { - if (!in_array($table, self::$excludeTables)) { + if (!in_array($table, self::$excludeTables, true) && $db->tableExists($table, false)) { $db->query('TRUNCATE TABLE ' . $db->escapeIdentifiers($table)); } } $db->enableForeignKeyChecks(); } + + private static function assertSafeTestDatabase(array $defaultConfig, array $testConfig): void + { + $defaultDatabase = (string) ($defaultConfig['database'] ?? ''); + $testDatabase = (string) ($testConfig['database'] ?? ''); + + if ($testDatabase === '') { + throw new \RuntimeException('Refusing to reset database: tests database name is empty.'); + } + + if ($defaultDatabase !== '' && $testDatabase === $defaultDatabase) { + throw new \RuntimeException(sprintf( + 'Refusing to reset database "%s": tests database matches the default application database.', + $testDatabase + )); + } + + if (! preg_match('/(^test_|_test$|^tests$|^alrahma_test$)/i', $testDatabase)) { + throw new \RuntimeException(sprintf( + 'Refusing to reset database "%s": test database names must clearly identify a test database.', + $testDatabase + )); + } + } } diff --git a/tests/_support/ModelCrudTestCase.php b/tests/_support/ModelCrudTestCase.php index 812ec3f..29bb052 100644 --- a/tests/_support/ModelCrudTestCase.php +++ b/tests/_support/ModelCrudTestCase.php @@ -18,6 +18,7 @@ abstract class ModelCrudTestCase extends CIUnitTestCase protected function assertModelCanInsertAndRetrieve(string $modelClass): void { $model = $this->newModel($modelClass); + $this->assertModelFieldsExist($model); $record = $this->fakeModelRecord($model); $id = $this->primaryKeyValue($record, $model->primaryKey); @@ -32,6 +33,7 @@ abstract class ModelCrudTestCase extends CIUnitTestCase protected function assertModelCanUpdate(string $modelClass): void { $model = $this->newModel($modelClass); + $this->assertModelFieldsExist($model); $record = $this->fakeModelRecord($model); $id = $this->primaryKeyValue($record, $model->primaryKey); $updateData = $this->updateData($model, $record); @@ -42,6 +44,7 @@ abstract class ModelCrudTestCase extends CIUnitTestCase protected function assertModelCanDelete(string $modelClass): void { $model = $this->newModel($modelClass); + $this->assertModelFieldsExist($model); $record = $this->fakeModelRecord($model); $id = $this->primaryKeyValue($record, $model->primaryKey); @@ -89,13 +92,34 @@ abstract class ModelCrudTestCase extends CIUnitTestCase protected function fakeModelRecord(Model $model): array|object { - if (! Database::connect('tests')->tableExists($model->table)) { - $this->markTestSkipped(sprintf('Table %s is not present in the migrated test schema.', $model->table)); - } - return fake($model, $this->fabricatorOverrides($model)); } + protected function assertModelFieldsExist(Model $model): void + { + $db = Database::connect('tests'); + + $this->assertTrue( + $db->tableExists($model->table, false), + sprintf('%s maps to table "%s", but that table is not present in the migrated test schema.', $model::class, $model->table) + ); + + $databaseFields = $db->getFieldNames($model->table); + + foreach ($model->allowedFields as $field) { + $this->assertContains( + $field, + $databaseFields, + sprintf( + '%s allows field "%s", but table "%s" does not contain it.', + $model::class, + $field, + $model->table + ) + ); + } + } + protected function fabricatorOverrides(Model $model): array { $overrides = []; @@ -184,9 +208,7 @@ abstract class ModelCrudTestCase extends CIUnitTestCase private function newModel(string $modelClass): Model { - if (! class_exists($modelClass)) { - $this->markTestSkipped(sprintf('Model %s is not part of the application.', $modelClass)); - } + $this->assertTrue(class_exists($modelClass), sprintf('Model %s is not part of the application.', $modelClass)); $model = new $modelClass(); diff --git a/tests/_support/import_baseline.php b/tests/_support/import_baseline.php index a49c39e..2d585da 100644 --- a/tests/_support/import_baseline.php +++ b/tests/_support/import_baseline.php @@ -29,6 +29,16 @@ foreach ($requiredEnv as $name) { } } +$testDatabase = (string) getenv('TEST_DB_NAME'); + +if (! preg_match('/(^test_|_test$|^tests$|^alrahma_test$)/i', $testDatabase)) { + fwrite( + STDERR, + "Refusing to import baseline into {$testDatabase}: TEST_DB_NAME must clearly identify a test database.\n" + ); + exit(1); +} + mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT); $db = new mysqli( diff --git a/tests/app/Models/ScoreModelTest.php b/tests/app/Models/ScoreModelTest.php deleted file mode 100644 index 763ff62..0000000 --- a/tests/app/Models/ScoreModelTest.php +++ /dev/null @@ -1,25 +0,0 @@ -assertModelCanInsertAndRetrieve(ScoreModel::class); - } - - public function testCanUpdate() - { - $this->assertModelCanUpdate(ScoreModel::class); - } - - public function testCanDelete() - { - $this->assertModelCanDelete(ScoreModel::class); - } -} diff --git a/tests/app/Models/StatsModelTest.php b/tests/app/Models/StatsModelTest.php deleted file mode 100644 index 398b2c0..0000000 --- a/tests/app/Models/StatsModelTest.php +++ /dev/null @@ -1,25 +0,0 @@ -assertModelCanInsertAndRetrieve(StatsModel::class); - } - - public function testCanUpdate() - { - $this->assertModelCanUpdate(StatsModel::class); - } - - public function testCanDelete() - { - $this->assertModelCanDelete(StatsModel::class); - } -} diff --git a/tests/app/Models/SupportRequestModelTest.php b/tests/app/Models/SupportRequestModelTest.php deleted file mode 100644 index d18f8d7..0000000 --- a/tests/app/Models/SupportRequestModelTest.php +++ /dev/null @@ -1,25 +0,0 @@ -assertModelCanInsertAndRetrieve(SupportRequestModel::class); - } - - public function testCanUpdate() - { - $this->assertModelCanUpdate(SupportRequestModel::class); - } - - public function testCanDelete() - { - $this->assertModelCanDelete(SupportRequestModel::class); - } -}