diff --git a/.github/workflows/contentauthor.yaml b/.github/workflows/contentauthor.yaml index 393e433ce5..ab8b3f69fd 100644 --- a/.github/workflows/contentauthor.yaml +++ b/.github/workflows/contentauthor.yaml @@ -77,6 +77,31 @@ jobs: if: "!inputs.deploy" runs-on: ubuntu-22.04 + services: + mariadb: + image: mariadb:11 + env: + MARIADB_ROOT_PASSWORD: mysqlpassword + MARIADB_DATABASE: edlibcontentauthor_test + ports: + - 3306:3306 + options: >- + --health-cmd="healthcheck.sh --connect --innodb_initialized" + --health-interval=5s + --health-timeout=5s + --health-retries=20 + postgres: + image: postgres:16-alpine + env: + POSTGRES_PASSWORD: thepgpassword + ports: + - 5432:5432 + options: >- + --health-cmd=pg_isready + --health-interval=5s + --health-timeout=5s + --health-retries=20 + steps: - name: Set up Buildx uses: docker/setup-buildx-action@v3 @@ -100,6 +125,9 @@ jobs: - name: Test run: | docker run -v "/tmp:/reports" --rm \ + --network host \ + -e DB_HOST=127.0.0.1 \ + -e DB_PGSQL_HOST=127.0.0.1 \ test-image \ vendor/bin/phpunit --coverage-clover /reports/coverage.xml diff --git a/docker-compose.yml b/docker-compose.yml index c515ced4da..eca6e25303 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -69,6 +69,7 @@ services: depends_on: contentauthor-startup: { condition: service_completed_successfully } mysql: { condition: service_healthy } + postgres: { condition: service_healthy } redis: { condition: service_healthy } healthcheck: test: [CMD, nc, -z, localhost, "9000"] @@ -86,6 +87,7 @@ services: depends_on: contentauthor-startup: { condition: service_completed_successfully } mysql: { condition: service_healthy } + postgres: { condition: service_healthy } redis: { condition: service_healthy } volumes: - ./data/nginx/ca:/usr/local/share/ca-certificates:ro @@ -101,6 +103,7 @@ services: depends_on: contentauthor-startup: { condition: service_completed_successfully } mysql: { condition: service_healthy } + postgres: { condition: service_healthy } redis: { condition: service_healthy } volumes: - ./data/nginx/ca:/usr/local/share/ca-certificates:ro @@ -116,6 +119,7 @@ services: depends_on: certs: { condition: service_completed_successfully } mysql: { condition: service_healthy } + postgres: { condition: service_healthy } redis: { condition: service_healthy } volumes: - ./data/nginx/ca:/usr/local/share/ca-certificates:ro diff --git a/localSetup/projects/mysql/init.sql b/localSetup/projects/mysql/init.sql index 8522f43932..e9c956bb79 100644 --- a/localSetup/projects/mysql/init.sql +++ b/localSetup/projects/mysql/init.sql @@ -1,2 +1,3 @@ CREATE DATABASE IF NOT EXISTS edlibcontentauthor; CREATE DATABASE IF NOT EXISTS moodle; +CREATE DATABASE IF NOT EXISTS edlibcontentauthor_test; diff --git a/localSetup/projects/postgres/postgresinit.d/hub.sql b/localSetup/projects/postgres/postgresinit.d/hub.sql index 52e783f9e1..9f56f981fc 100644 --- a/localSetup/projects/postgres/postgresinit.d/hub.sql +++ b/localSetup/projects/postgres/postgresinit.d/hub.sql @@ -1,2 +1,3 @@ CREATE DATABASE hub; CREATE DATABASE hub_test; +CREATE DATABASE contentauthor; diff --git a/sourcecode/apis/contentauthor/.env.example b/sourcecode/apis/contentauthor/.env.example index 2e1142919f..eff4f9236a 100644 --- a/sourcecode/apis/contentauthor/.env.example +++ b/sourcecode/apis/contentauthor/.env.example @@ -12,6 +12,7 @@ LOG_STACK_CHANNELS=stderr,single LOG_LEVEL=debug #LOG_STDERR_FORMATTER='App\Logging\JsonFormatter' +# To run contentauthor with postgresql switch the comments for these blocks. DB_CONNECTION=mysql DB_HOST=mysql DB_PORT=3306 @@ -19,6 +20,13 @@ DB_DATABASE=edlibcontentauthor DB_USERNAME=root DB_PASSWORD=mysqlpassword +# DB_CONNECTION=pgsql +# DB_HOST=postgres +# DB_PORT=5432 +# DB_DATABASE=contentauthor +# DB_USERNAME=postgres +# DB_PASSWORD=thepgpassword + REDIS_HOST=redis REDIS_PASSWORD=null REDIS_PORT=6379 diff --git a/sourcecode/apis/contentauthor/Dockerfile b/sourcecode/apis/contentauthor/Dockerfile index 8b4be7ffb2..75bc2ddecd 100644 --- a/sourcecode/apis/contentauthor/Dockerfile +++ b/sourcecode/apis/contentauthor/Dockerfile @@ -15,6 +15,7 @@ RUN install-php-extensions \ pcntl \ pcov \ pdo_mysql \ + pdo_pgsql \ redis \ zip diff --git a/sourcecode/apis/contentauthor/app/H5PLibrary.php b/sourcecode/apis/contentauthor/app/H5PLibrary.php index a62783c332..34837ad9d6 100644 --- a/sourcecode/apis/contentauthor/app/H5PLibrary.php +++ b/sourcecode/apis/contentauthor/app/H5PLibrary.php @@ -323,15 +323,15 @@ public function getAddons() }); }) ->select([ - 'l1.id as libraryId', - 'l1.add_to as addTo', - 'l1.name as machineName', - 'l1.major_version as majorVersion', - 'l1.minor_version as minorVersion', - 'l1.patch_version as patchVersion', - 'l1.preloaded_js as preloadedJs', - 'l1.preloaded_css as preloadedCss', - 'l1.patch_version_in_folder_name as patchVersionInFolderName', + 'l1.id as \"libraryId\"', + 'l1.add_to as \"addTo\"', + 'l1.name as \"machineName\"', + 'l1.major_version as \"majorVersion\"', + 'l1.minor_version as \"minorVersion\"', + 'l1.patch_version as \"patchVersion\"', + 'l1.preloaded_js as \"preloadedJs\"', + 'l1.preloaded_css as \"preloadedCss\"', + 'l1.patch_version_in_folder_name as \"patchVersionInFolderName\"', ]) ->whereNull('l2.name') ->whereNotNull('l1.add_to') diff --git a/sourcecode/apis/contentauthor/app/Http/Controllers/Admin/AdminController.php b/sourcecode/apis/contentauthor/app/Http/Controllers/Admin/AdminController.php index b315f2820b..ba8f00ec36 100644 --- a/sourcecode/apis/contentauthor/app/Http/Controllers/Admin/AdminController.php +++ b/sourcecode/apis/contentauthor/app/Http/Controllers/Admin/AdminController.php @@ -40,7 +40,6 @@ public function viewMaxScoreOverview() }, ]) ->groupBy('id') - ->having('contents_count', ">", 0) ->orderBy('name') ->get() ->filter(fn(H5PLibrary $library) => $library->supportsMaxScore()); diff --git a/sourcecode/apis/contentauthor/app/Http/Controllers/Admin/LibraryUpgradeController.php b/sourcecode/apis/contentauthor/app/Http/Controllers/Admin/LibraryUpgradeController.php index c1ddb3681d..9c9378573b 100644 --- a/sourcecode/apis/contentauthor/app/Http/Controllers/Admin/LibraryUpgradeController.php +++ b/sourcecode/apis/contentauthor/app/Http/Controllers/Admin/LibraryUpgradeController.php @@ -20,7 +20,6 @@ use Illuminate\Http\Response; use Illuminate\Http\UploadedFile; use Illuminate\Support\Collection; -use Illuminate\Support\Facades\DB; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; class LibraryUpgradeController extends Controller @@ -46,12 +45,11 @@ public function index(Request $request): View 'patch_version', 'runnable', 'restricted', - DB::raw('UNIX_TIMESTAMP(created_at) as created_ts'), - DB::raw('UNIX_TIMESTAMP(updated_at) as updated_ts'), + 'created_at', + 'updated_at', ]) ->orderBy('major_version') ->orderBy('minor_version') - ->getQuery() ->get() ->mapToGroups(function ($item) { return [$item->name => $item]; @@ -105,13 +103,13 @@ public function index(Request $request): View 'hubUpgradeMessage' => '', 'hubAvailable' => false, 'canRebuild' => !empty($library->id), - 'createdTs' => $library->created_ts, - 'updatedTs' => $library->updated_ts, + 'createdTs' => $library->created_at->timestamp, + 'updatedTs' => $library->updated_at->timestamp, ]; if ($library->runnable) { if (config('h5p.isHubEnabled')) { - $upgrades = $this->core->getUpgrades($library, $versions->toArray()); + $upgrades = $this->core->getUpgrades($library, $versions->all()); $item['upgradeUrl'] = empty($upgrades) || empty($usage['content']) ? false : route('admin.library', [ 'task' => 'upgrade', diff --git a/sourcecode/apis/contentauthor/app/Libraries/H5P/Framework.php b/sourcecode/apis/contentauthor/app/Libraries/H5P/Framework.php index 59a5fae31d..27bee9861e 100644 --- a/sourcecode/apis/contentauthor/app/Libraries/H5P/Framework.php +++ b/sourcecode/apis/contentauthor/app/Libraries/H5P/Framework.php @@ -951,15 +951,15 @@ public function loadContentDependencies($id, $type = null) } $sql = "SELECT hl.id - , hl.name AS machineName - , hl.major_version AS majorVersion - , hl.minor_version AS minorVersion - , hl.patch_version AS patchVersion - , hl.preloaded_css AS preloadedCss - , hl.preloaded_js AS preloadedJs - , hcl.drop_css AS dropCss - , hcl.dependency_type AS dependencyType - , hl.patch_version_in_folder_name AS patchVersionInFolderName + , hl.name AS \"machineName\" + , hl.major_version AS \"majorVersion\" + , hl.minor_version AS \"minorVersion\" + , hl.patch_version AS \"patchVersion\" + , hl.preloaded_css AS \"preloadedCss\" + , hl.preloaded_js AS \"preloadedJs\" + , hcl.drop_css AS \"dropCss\" + , hcl.dependency_type AS \"dependencyType\" + , hl.patch_version_in_folder_name AS \"patchVersionInFolderName\" FROM h5p_contents_libraries hcl JOIN h5p_libraries hl ON hcl.library_id = hl.id WHERE hcl.content_id = ?"; @@ -1208,8 +1208,8 @@ public function replaceContentTypeCache($contentTypeCache) 'major_version' => $type->version->major, 'minor_version' => $type->version->minor, 'patch_version' => $type->version->patch, - 'h5P_major_version' => $type->coreApiVersionNeeded->major, - 'h5P_minor_version' => $type->coreApiVersionNeeded->minor, + 'h5p_major_version' => $type->coreApiVersionNeeded->major, + 'h5p_minor_version' => $type->coreApiVersionNeeded->minor, 'title' => $type->title, 'summary' => $type->summary, 'description' => $type->description, diff --git a/sourcecode/apis/contentauthor/app/Libraries/H5P/H5PProgress.php b/sourcecode/apis/contentauthor/app/Libraries/H5P/H5PProgress.php index 44a0dcfa68..ab411c76a9 100644 --- a/sourcecode/apis/contentauthor/app/Libraries/H5P/H5PProgress.php +++ b/sourcecode/apis/contentauthor/app/Libraries/H5P/H5PProgress.php @@ -152,7 +152,7 @@ public function getProgress(Request $request) public function getState($contentId, $context) { - $sql = "select data, sub_content_id, data_id from $this->tableName where content_id=:contentId and user_id=:userId and preload=1"; + $sql = "select data, sub_content_id, data_id from $this->tableName where content_id=:contentId and user_id=:userId and preload=true"; $params = [ ':contentId' => $contentId, ':userId' => $this->currentUserId, diff --git a/sourcecode/apis/contentauthor/config/queue.php b/sourcecode/apis/contentauthor/config/queue.php index 3e15ddffdc..a8a963f273 100644 --- a/sourcecode/apis/contentauthor/config/queue.php +++ b/sourcecode/apis/contentauthor/config/queue.php @@ -94,7 +94,7 @@ */ 'failed' => [ - 'database' => 'mysql', 'table' => 'failed_jobs', + 'database' => env('DB_CONNECTION', 'mysql'), 'table' => 'failed_jobs', ], ]; diff --git a/sourcecode/apis/contentauthor/database/migrations/2015_09_25_092958_create_h5p_contents_user_data_table.php b/sourcecode/apis/contentauthor/database/migrations/2015_09_25_092958_create_h5p_contents_user_data_table.php index 8bdc420b57..0894f832dc 100644 --- a/sourcecode/apis/contentauthor/database/migrations/2015_09_25_092958_create_h5p_contents_user_data_table.php +++ b/sourcecode/apis/contentauthor/database/migrations/2015_09_25_092958_create_h5p_contents_user_data_table.php @@ -20,7 +20,7 @@ public function up() $table->text('data'); $table->boolean('preload')->default(0); $table->boolean('invalidate')->default(0); - $table->dateTime('updated_at')->default('0000-00-00 00:00:00'); + $table->dateTime('updated_at')->useCurrent(); $table->primary(['content_id','user_id','sub_content_id','data_id'], 'cud_pk1'); }); } diff --git a/sourcecode/apis/contentauthor/database/migrations/2015_09_25_092958_create_h5p_libraries_languages_table.php b/sourcecode/apis/contentauthor/database/migrations/2015_09_25_092958_create_h5p_libraries_languages_table.php index 23e3c3408b..4c4ee8b132 100644 --- a/sourcecode/apis/contentauthor/database/migrations/2015_09_25_092958_create_h5p_libraries_languages_table.php +++ b/sourcecode/apis/contentauthor/database/migrations/2015_09_25_092958_create_h5p_libraries_languages_table.php @@ -13,10 +13,11 @@ class CreateH5pLibrariesLanguagesTable extends Migration public function up() { Schema::create('h5p_libraries_languages', function (Blueprint $table) { + $table->increments('id'); $table->integer('library_id')->unsigned(); $table->string('language_code', 31); $table->text('translation', 65535); - $table->primary(['library_id','language_code']); + $table->unique(['library_id','language_code']); }); } diff --git a/sourcecode/apis/contentauthor/database/migrations/2015_09_25_092958_create_h5p_libraries_libraries_table.php b/sourcecode/apis/contentauthor/database/migrations/2015_09_25_092958_create_h5p_libraries_libraries_table.php index 582d191b5f..69a6be5dfe 100644 --- a/sourcecode/apis/contentauthor/database/migrations/2015_09_25_092958_create_h5p_libraries_libraries_table.php +++ b/sourcecode/apis/contentauthor/database/migrations/2015_09_25_092958_create_h5p_libraries_libraries_table.php @@ -13,10 +13,11 @@ class CreateH5pLibrariesLibrariesTable extends Migration public function up() { Schema::create('h5p_libraries_libraries', function (Blueprint $table) { + $table->increments('id'); $table->integer('library_id')->unsigned(); $table->integer('required_library_id')->unsigned(); $table->string('dependency_type', 31); - $table->primary(['library_id','required_library_id']); + $table->unique(['library_id','required_library_id']); }); } diff --git a/sourcecode/apis/contentauthor/database/migrations/2015_10_02_083208_update-user_id.php b/sourcecode/apis/contentauthor/database/migrations/2015_10_02_083208_update-user_id.php index df5947c6da..03b5ee576b 100644 --- a/sourcecode/apis/contentauthor/database/migrations/2015_10_02_083208_update-user_id.php +++ b/sourcecode/apis/contentauthor/database/migrations/2015_10_02_083208_update-user_id.php @@ -29,6 +29,15 @@ public function up() */ public function down() { + // Postgres needs an explicit cast to turn a string column into an integer + if (DB::connection()->getDriverName() === 'pgsql') { + foreach (['h5p_contents', 'h5p_contents_user_data', 'h5p_results'] as $tableName) { + DB::statement("ALTER TABLE $tableName ALTER COLUMN user_id TYPE integer USING user_id::integer"); + } + + return; + } + Schema::table('h5p_contents', function ($table) { $table->integer('user_id')->unsigned()->change(); }); diff --git a/sourcecode/apis/contentauthor/database/migrations/2015_12_15_081807_create_content_sharing_table.php b/sourcecode/apis/contentauthor/database/migrations/2015_12_15_081807_create_content_sharing_table.php index 8fb0264b8e..ec0afb74f7 100644 --- a/sourcecode/apis/contentauthor/database/migrations/2015_12_15_081807_create_content_sharing_table.php +++ b/sourcecode/apis/contentauthor/database/migrations/2015_12_15_081807_create_content_sharing_table.php @@ -15,7 +15,7 @@ public function up() Schema::create('cerpus_contents_shares', function (Blueprint $table) { $table->integer('h5p_id')->unsigned(); $table->text('email', 65535); - $table->dateTime('created_at')->default('0000-00-00 00:00:00'); + $table->dateTime('created_at')->useCurrent(); }); } diff --git a/sourcecode/apis/contentauthor/database/migrations/2016_04_27_123915_create_h5p_options_table.php b/sourcecode/apis/contentauthor/database/migrations/2016_04_27_123915_create_h5p_options_table.php index f7ea15802c..9695dbb2f0 100644 --- a/sourcecode/apis/contentauthor/database/migrations/2016_04_27_123915_create_h5p_options_table.php +++ b/sourcecode/apis/contentauthor/database/migrations/2016_04_27_123915_create_h5p_options_table.php @@ -17,7 +17,7 @@ public function up() $table->bigIncrements('option_id')->unsigned(); $table->string('option_name', 191)->nullable()->default(null); $table->longText('option_value'); - $table->string('autoload', 20); + $table->string('autoload', 20)->default(0); }); } } diff --git a/sourcecode/apis/contentauthor/database/migrations/2016_05_25_130842_add_h5p_context.php b/sourcecode/apis/contentauthor/database/migrations/2016_05_25_130842_add_h5p_context.php index b6c5bb29db..20e590a685 100644 --- a/sourcecode/apis/contentauthor/database/migrations/2016_05_25_130842_add_h5p_context.php +++ b/sourcecode/apis/contentauthor/database/migrations/2016_05_25_130842_add_h5p_context.php @@ -35,7 +35,7 @@ public function up() $table->text('data'); $table->boolean('preload')->default(0); $table->boolean('invalidate')->default(0); - $table->dateTime('updated_at')->default('0000-00-00 00:00:00'); + $table->dateTime('updated_at')->useCurrent(); $table->string('context', 40)->nullable()->default(null); $table->unique(['content_id', 'user_id', 'sub_content_id', 'data_id', 'context'], 'cu_pid'); }); @@ -57,10 +57,17 @@ public function up() public function down() { Schema::table('h5p_contents_user_data', function ($table) { - $table->dropColumn(["id", "context"]); + $table->dropUnique('cu_pid'); if (!DB::connection() instanceof \Illuminate\Database\SQLiteConnection) { - $table->dropUnique('cu_pid'); + $table->dropIndex(['context']); } + }); + + Schema::table('h5p_contents_user_data', function ($table) { + $table->dropColumn(["id", "context"]); + }); + + Schema::table('h5p_contents_user_data', function ($table) { $table->primary(['content_id', 'user_id', 'sub_content_id', 'data_id'], 'cud_pk1'); }); diff --git a/sourcecode/apis/contentauthor/database/migrations/2016_08_01_085342_increase_h5p_contents_textfield_size.php b/sourcecode/apis/contentauthor/database/migrations/2016_08_01_085342_increase_h5p_contents_textfield_size.php index ac8b49799a..66c8375bab 100644 --- a/sourcecode/apis/contentauthor/database/migrations/2016_08_01_085342_increase_h5p_contents_textfield_size.php +++ b/sourcecode/apis/contentauthor/database/migrations/2016_08_01_085342_increase_h5p_contents_textfield_size.php @@ -14,12 +14,12 @@ class IncreaseH5pContentsTextfieldSize extends Migration public function up() { Schema::table('h5p_contents', function ($table) { - $table->string('parameters', self::longTextLength)->change(); - $table->string('filtered', self::longTextLength)->change(); + $table->longText('parameters')->change(); + $table->longText('filtered')->change(); }); Schema::table('h5p_contents_user_data', function ($table) { - $table->string('data', self::longTextLength)->change(); + $table->longText('data')->change(); }); } diff --git a/sourcecode/apis/contentauthor/database/migrations/2016_10_20_071210_set_all_content_private.php b/sourcecode/apis/contentauthor/database/migrations/2016_10_20_071210_set_all_content_private.php index c095127e2e..0069638276 100644 --- a/sourcecode/apis/contentauthor/database/migrations/2016_10_20_071210_set_all_content_private.php +++ b/sourcecode/apis/contentauthor/database/migrations/2016_10_20_071210_set_all_content_private.php @@ -7,7 +7,7 @@ class SetAllContentPrivate extends Migration public function up() { DB::update('update articles set is_private=1'); - DB::update('update h5p_contents set is_private=1'); + DB::update('update h5p_contents set is_private=true'); } public function down() diff --git a/sourcecode/apis/contentauthor/database/migrations/2018_07_12_112426_add_millionaire_contenttype.php b/sourcecode/apis/contentauthor/database/migrations/2018_07_12_112426_add_millionaire_contenttype.php index d14c93b76e..bfcaa0a85f 100644 --- a/sourcecode/apis/contentauthor/database/migrations/2018_07_12_112426_add_millionaire_contenttype.php +++ b/sourcecode/apis/contentauthor/database/migrations/2018_07_12_112426_add_millionaire_contenttype.php @@ -31,7 +31,7 @@ public function up() if (!Schema::hasTable('games')) { Schema::create('games', function (Blueprint $table) { $table->uuid('id'); - $table->string('gametype'); + $table->uuid('gametype'); $table->string('title'); $table->string('language_code'); $table->string('owner'); diff --git a/sourcecode/apis/contentauthor/database/migrations/2019_06_24_105205_change_ndla_id_field_to_string.php b/sourcecode/apis/contentauthor/database/migrations/2019_06_24_105205_change_ndla_id_field_to_string.php index cfda5f914f..4933fea3f8 100644 --- a/sourcecode/apis/contentauthor/database/migrations/2019_06_24_105205_change_ndla_id_field_to_string.php +++ b/sourcecode/apis/contentauthor/database/migrations/2019_06_24_105205_change_ndla_id_field_to_string.php @@ -1,5 +1,6 @@ getDriverName() === 'pgsql') { + DB::statement('ALTER TABLE ndla_article_import_statuses ALTER COLUMN ndla_id TYPE integer USING ndla_id::integer'); + + return; + } + Schema::table('ndla_article_import_statuses', function (Blueprint $table) { $table->unsignedInteger('ndla_id')->change(); }); diff --git a/sourcecode/apis/contentauthor/database/migrations/2026_08_19_130000_change_games_gametype_to_uuid.php b/sourcecode/apis/contentauthor/database/migrations/2026_08_19_130000_change_games_gametype_to_uuid.php new file mode 100644 index 0000000000..5b9fce6401 --- /dev/null +++ b/sourcecode/apis/contentauthor/database/migrations/2026_08_19_130000_change_games_gametype_to_uuid.php @@ -0,0 +1,47 @@ +dropForeign(['gametype']); + }); + + Schema::table('games', function (Blueprint $table) { + $table->uuid('gametype')->change(); + }); + + Schema::table('games', function (Blueprint $table) { + $table->foreign('gametype')->references('id')->on('gametypes'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + // Nothing to revert: "gametype" should be a uuid on every database, and + // changing it back to a varchar breaks the foreign key on Postgres. + } +}; diff --git a/sourcecode/apis/contentauthor/database/migrations/2026_08_19_140000_fix_h5p_options_autoload_null.php b/sourcecode/apis/contentauthor/database/migrations/2026_08_19_140000_fix_h5p_options_autoload_null.php new file mode 100644 index 0000000000..383f18031e --- /dev/null +++ b/sourcecode/apis/contentauthor/database/migrations/2026_08_19_140000_fix_h5p_options_autoload_null.php @@ -0,0 +1,34 @@ +whereNull('autoload') + ->update(['autoload' => 0]); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + // Not reversible: original NULL rows can't be distinguished from + // rows that legitimately had autoload = 0. + } +}; diff --git a/sourcecode/apis/contentauthor/database/migrations/2026_08_20_100000_replace_h5p_libraries_languages_primary_key.php b/sourcecode/apis/contentauthor/database/migrations/2026_08_20_100000_replace_h5p_libraries_languages_primary_key.php new file mode 100644 index 0000000000..e1894506b4 --- /dev/null +++ b/sourcecode/apis/contentauthor/database/migrations/2026_08_20_100000_replace_h5p_libraries_languages_primary_key.php @@ -0,0 +1,62 @@ +hasCompositePrimaryKey('h5p_libraries_languages')) { + return; + } + + Schema::table('h5p_libraries_languages', function (Blueprint $table) { + $table->dropPrimary(['library_id', 'language_code']); + }); + + Schema::table('h5p_libraries_languages', function (Blueprint $table) { + $table->increments('id')->first(); + $table->unique(['library_id', 'language_code']); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + if ($this->hasCompositePrimaryKey('h5p_libraries_languages')) { + return; + } + + Schema::table('h5p_libraries_languages', function (Blueprint $table) { + $table->dropUnique(['library_id', 'language_code']); + $table->dropColumn('id'); + }); + + Schema::table('h5p_libraries_languages', function (Blueprint $table) { + $table->primary(['library_id', 'language_code']); + }); + } + + private function hasCompositePrimaryKey(string $table): bool + { + $primaryKey = collect(Schema::getIndexes($table))->firstWhere('primary', true); + + return count($primaryKey['columns'] ?? []) > 1; + } +}; diff --git a/sourcecode/apis/contentauthor/database/migrations/2026_08_20_110000_replace_h5p_libraries_libraries_primary_key.php b/sourcecode/apis/contentauthor/database/migrations/2026_08_20_110000_replace_h5p_libraries_libraries_primary_key.php new file mode 100644 index 0000000000..035ae63a16 --- /dev/null +++ b/sourcecode/apis/contentauthor/database/migrations/2026_08_20_110000_replace_h5p_libraries_libraries_primary_key.php @@ -0,0 +1,62 @@ +hasCompositePrimaryKey('h5p_libraries_libraries')) { + return; + } + + Schema::table('h5p_libraries_libraries', function (Blueprint $table) { + $table->dropPrimary(['library_id', 'required_library_id']); + }); + + Schema::table('h5p_libraries_libraries', function (Blueprint $table) { + $table->increments('id')->first(); + $table->unique(['library_id', 'required_library_id']); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + if ($this->hasCompositePrimaryKey('h5p_libraries_libraries')) { + return; + } + + Schema::table('h5p_libraries_libraries', function (Blueprint $table) { + $table->dropUnique(['library_id', 'required_library_id']); + $table->dropColumn('id'); + }); + + Schema::table('h5p_libraries_libraries', function (Blueprint $table) { + $table->primary(['library_id', 'required_library_id']); + }); + } + + private function hasCompositePrimaryKey(string $table): bool + { + $primaryKey = collect(Schema::getIndexes($table))->firstWhere('primary', true); + + return count($primaryKey['columns'] ?? []) > 1; + } +}; diff --git a/sourcecode/apis/contentauthor/phpunit.xml b/sourcecode/apis/contentauthor/phpunit.xml index 0d0af84618..6f02041a19 100644 --- a/sourcecode/apis/contentauthor/phpunit.xml +++ b/sourcecode/apis/contentauthor/phpunit.xml @@ -33,8 +33,18 @@ - - + + + + + + + + + + + + diff --git a/sourcecode/apis/contentauthor/tests/Integration/Article/ArticleVersioningTest.php b/sourcecode/apis/contentauthor/tests/Integration/Article/ArticleVersioningTest.php index f257bc0365..4d827693bd 100644 --- a/sourcecode/apis/contentauthor/tests/Integration/Article/ArticleVersioningTest.php +++ b/sourcecode/apis/contentauthor/tests/Integration/Article/ArticleVersioningTest.php @@ -26,12 +26,18 @@ public function testDatabaseVersioning() { $authId = Str::uuid(); $article = Article::factory()->create(['owner_id' => $authId]); + ContentVersion::factory()->create([ + 'id' => $article->version_id, + 'content_id' => $article->id, + 'content_type' => Content::TYPE_ARTICLE, + ]); $startCount = Article::count(); $this->withSession(['authId' => $authId]) ->put(route('article.update', $article->id), [ 'title' => 'Title', 'content' => 'Content', - ]); + ]) + ->assertStatus(Response::HTTP_CREATED); $this->assertDatabaseHas('articles', [ // See the new 'title' => 'Title', 'content' => 'Content', diff --git a/sourcecode/apis/contentauthor/tests/Integration/Commands/EnsureVersionExistsTest.php b/sourcecode/apis/contentauthor/tests/Integration/Commands/EnsureVersionExistsTest.php index 34e7498936..87110d2cd7 100644 --- a/sourcecode/apis/contentauthor/tests/Integration/Commands/EnsureVersionExistsTest.php +++ b/sourcecode/apis/contentauthor/tests/Integration/Commands/EnsureVersionExistsTest.php @@ -17,12 +17,18 @@ class EnsureVersionExistsTest extends TestCase public function test_articles(): void { + // The command processes articles ordered by created_at, so make it unique + $seconds = 0; + $createdAt = function () use (&$seconds) { + return ['created_at' => now()->addSeconds(++$seconds)]; + }; + // Not versioned - $noVersion = Article::factory(2)->create([ + $noVersion = Article::factory(2)->sequence($createdAt)->create([ 'version_id' => null, ]); // With version - $versioned = Article::factory(2)->create()->each(function (Article $article) { + $versioned = Article::factory(2)->sequence($createdAt)->create()->each(function (Article $article) { ContentVersion::factory()->create([ 'id' => $article->version_id, 'content_id' => $article->id, @@ -30,10 +36,10 @@ public function test_articles(): void ]); }); // Version record missing - $missing = Article::factory(2)->create(); + $missing = Article::factory(2)->sequence($createdAt)->create(); // Unconnected version $unconnectedVersions = collect(); - $notConnected = Article::factory(2)->create([ + $notConnected = Article::factory(2)->sequence($createdAt)->create([ 'version_id' => null, ])->each(function (Article $article) use ($unconnectedVersions) { $unconnectedVersions->add(ContentVersion::factory()->create([ diff --git a/sourcecode/apis/contentauthor/tests/Integration/Http/Controllers/Admin/AdminH5PDetailsControllerTest.php b/sourcecode/apis/contentauthor/tests/Integration/Http/Controllers/Admin/AdminH5PDetailsControllerTest.php index 9f654d414e..c3f467a854 100644 --- a/sourcecode/apis/contentauthor/tests/Integration/Http/Controllers/Admin/AdminH5PDetailsControllerTest.php +++ b/sourcecode/apis/contentauthor/tests/Integration/Http/Controllers/Admin/AdminH5PDetailsControllerTest.php @@ -244,7 +244,6 @@ public function test_contentHistory(): void 'library_id' => $library->id, ]); $content = H5PContent::factory()->create([ - 'id' => 42, 'version_id' => $this->faker->uuid, 'library_id' => $library->id, ]); @@ -305,7 +304,6 @@ public function test_contentHistory_noResource(): void ]); $library = H5PLibrary::factory()->create(); $content = H5PContent::factory()->create([ - 'id' => 42, 'version_id' => $this->faker->uuid, 'library_id' => $library->id, ]); diff --git a/sourcecode/apis/contentauthor/tests/Integration/Libraries/H5P/CRUTest.php b/sourcecode/apis/contentauthor/tests/Integration/Libraries/H5P/CRUTest.php index 3cf4ce76b6..ecc0631fd5 100644 --- a/sourcecode/apis/contentauthor/tests/Integration/Libraries/H5P/CRUTest.php +++ b/sourcecode/apis/contentauthor/tests/Integration/Libraries/H5P/CRUTest.php @@ -86,13 +86,14 @@ public function create_and_update_h5p_using_web_request(bool $useLinearVersionin ->assertStatus(Response::HTTP_CREATED); // Redirects after save $this->assertDatabaseCount('h5p_contents', 1); - $h5p = H5PContent::find(1); + $h5p = H5PContent::sole(); + $firstId = $h5p->id; $this->assertCount(1, $h5p->collaborators); - $this->assertDatabaseHas('h5p_contents', ['id' => 1, 'title' => 'Tittel']); + $this->assertDatabaseHas('h5p_contents', ['id' => $firstId, 'title' => 'Tittel']); $firstVersion = $h5p->version_id; $this->assertDatabaseHas('content_versions', [ 'id' => $firstVersion, - 'content_id' => 1, + 'content_id' => $firstId, 'content_type' => Content::TYPE_H5P, 'version_purpose' => ContentVersion::PURPOSE_CREATE, ]); @@ -103,7 +104,7 @@ public function create_and_update_h5p_using_web_request(bool $useLinearVersionin 'email' => $owner->email, 'verifiedEmails' => [$owner->email], ]) - ->put(route('h5p.update', 1), [ + ->put(route('h5p.update', $firstId), [ '_token' => csrf_token(), 'title' => 'Tittel', 'action' => 'create', @@ -121,14 +122,14 @@ public function create_and_update_h5p_using_web_request(bool $useLinearVersionin $h5p->refresh(); $this->assertDatabaseCount('h5p_contents', 1); $this->assertCount(2, $h5p->collaborators); - $this->assertDatabaseHas('h5p_contents', ['id' => 1, 'title' => 'Tittel']); + $this->assertDatabaseHas('h5p_contents', ['id' => $firstId, 'title' => 'Tittel']); $this->assertDatabaseCount('content_versions', 2); $secondVersion = $h5p->version_id; $this->assertDatabaseHas('content_versions', [ 'id' => $secondVersion, 'parent_id' => $firstVersion, - 'content_id' => 1, + 'content_id' => $firstId, 'content_type' => Content::TYPE_H5P, 'version_purpose' => ContentVersion::PURPOSE_UPDATE, ]); @@ -139,7 +140,7 @@ public function create_and_update_h5p_using_web_request(bool $useLinearVersionin 'email' => $owner->email, 'verifiedEmails' => [$owner->email], ]) - ->put(route('h5p.update', 1), [ + ->put(route('h5p.update', $firstId), [ '_token' => csrf_token(), 'title' => 'Tittel 2', // Will trigger a new version 'action' => 'create', @@ -154,16 +155,17 @@ public function create_and_update_h5p_using_web_request(bool $useLinearVersionin ]); $this->assertDatabaseCount('h5p_contents', 2); - $this->assertCount(2, H5PContent::find(1)->collaborators); // Original still has two collaborators - $this->assertCount(3, H5PContent::find(2)->collaborators); // New has three collaborators - $this->assertDatabaseHas('h5p_contents', ['id' => 1, 'title' => 'Tittel']); - $this->assertDatabaseHas('h5p_contents', ['id' => 2, 'title' => 'Tittel 2']); + $secondId = H5PContent::max('id'); + $this->assertCount(2, H5PContent::find($firstId)->collaborators); // Original still has two collaborators + $this->assertCount(3, H5PContent::find($secondId)->collaborators); // New has three collaborators + $this->assertDatabaseHas('h5p_contents', ['id' => $firstId, 'title' => 'Tittel']); + $this->assertDatabaseHas('h5p_contents', ['id' => $secondId, 'title' => 'Tittel 2']); $this->assertDatabaseCount('content_versions', 3); - $thirdVersion = H5PContent::find(2)->version_id; + $thirdVersion = H5PContent::find($secondId)->version_id; $this->assertDatabaseHas('content_versions', [ 'id' => $thirdVersion, - 'content_id' => 2, + 'content_id' => $secondId, 'parent_id' => $secondVersion, 'version_purpose' => ContentVersion::PURPOSE_UPDATE, ]); @@ -190,14 +192,15 @@ public function create_and_update_h5p_using_web_request(bool $useLinearVersionin ->assertStatus(Response::HTTP_OK); $this->assertDatabaseCount('h5p_contents', 3); - $this->assertCount(2, H5PContent::find(3)->collaborators); // Collaborators not updated + $thirdId = H5PContent::max('id'); + $this->assertCount(2, H5PContent::find($thirdId)->collaborators); // Collaborators not updated $this->assertDatabaseHas('h5p_contents', ['user_id' => $owner->auth_id, 'title' => 'Tittel 3']); // Owner has not changed, title updated $this->assertDatabaseCount('content_versions', 4); - $fourthVersion = H5PContent::find(3)->version_id; + $fourthVersion = H5PContent::find($thirdId)->version_id; $this->assertDatabaseHas('content_versions', [ 'id' => $fourthVersion, - 'content_id' => 3, + 'content_id' => $thirdId, 'parent_id' => $useLinearVersioning ? $thirdVersion : $secondVersion, 'version_purpose' => ContentVersion::PURPOSE_UPDATE, ]); @@ -227,13 +230,14 @@ public function create_and_update_h5p_using_web_request(bool $useLinearVersionin ->assertStatus(Response::HTTP_OK); $this->assertDatabaseCount('h5p_contents', 4); // New H5P in db + $fourthId = H5PContent::max('id'); $this->assertDatabaseHas('h5p_contents', ['user_id' => $copyist->auth_id, 'title' => 'Tittel 4']); // Owner and title updated - $this->assertCount(0, H5PContent::find(4)->collaborators); //No collaborators on new resource + $this->assertCount(0, H5PContent::find($fourthId)->collaborators); //No collaborators on new resource $this->assertDatabaseCount('content_versions', 5); $this->assertDatabaseHas('content_versions', [ - 'id' => H5PContent::find(4)->version_id, - 'content_id' => 4, + 'id' => H5PContent::find($fourthId)->version_id, + 'content_id' => $fourthId, 'parent_id' => $useLinearVersioning ? $fourthVersion : $secondVersion, 'version_purpose' => ContentVersion::PURPOSE_COPY, ]); diff --git a/sourcecode/apis/contentauthor/tests/Integration/Libraries/H5P/H5PViewConfigTest.php b/sourcecode/apis/contentauthor/tests/Integration/Libraries/H5P/H5PViewConfigTest.php index 9c7591dba4..7f64d2cc1a 100644 --- a/sourcecode/apis/contentauthor/tests/Integration/Libraries/H5P/H5PViewConfigTest.php +++ b/sourcecode/apis/contentauthor/tests/Integration/Libraries/H5P/H5PViewConfigTest.php @@ -130,8 +130,8 @@ public function test_loadContent(string $adapterMode): void ->loadContent($content->id) ->getConfig(); - $this->assertDatabaseHas('h5p_contents_libraries', ['content_id' => 1, 'library_id' => $library->id, 'dependency_type' => 'preloaded']); - $this->assertDatabaseHas('h5p_contents_libraries', ['content_id' => 1, 'library_id' => $dependency->id, 'dependency_type' => 'preloaded']); + $this->assertDatabaseHas('h5p_contents_libraries', ['content_id' => $content->id, 'library_id' => $library->id, 'dependency_type' => 'preloaded']); + $this->assertDatabaseHas('h5p_contents_libraries', ['content_id' => $content->id, 'library_id' => $dependency->id, 'dependency_type' => 'preloaded']); $this->assertTrue($data->postUserStatistics); $this->assertObjectHasProperty('cid-' . $content->id, $data->contents); diff --git a/sourcecode/apis/contentauthor/tests/Integration/Libraries/H5P/h5pTest.php b/sourcecode/apis/contentauthor/tests/Integration/Libraries/H5P/h5pTest.php index fe8eaaf975..f3a023db7b 100644 --- a/sourcecode/apis/contentauthor/tests/Integration/Libraries/H5P/h5pTest.php +++ b/sourcecode/apis/contentauthor/tests/Integration/Libraries/H5P/h5pTest.php @@ -119,7 +119,7 @@ public function createWithOutVersioningContent() $h5p = app(h5p::class); $content = $h5p->storeContent($request, null, "createContentUserId"); $this->assertNotFalse($content); - $this->assertEquals(1, $content['id']); + $this->assertEquals(H5PContent::max('id'), $content['id']); $this->assertEquals("My Test Title", $content['title']); $this->assertEquals("createContentUserId", $content['user_id']); $this->assertFileExists("{$this->getContentDirectory()}/{$content['id']}/images/image-5805bff7c5330.jpg"); @@ -130,8 +130,8 @@ public function createWithOutVersioningContent() $this->assertEquals("Hvor er ørreten?", $contentParamsDecoded->cards[0]->text); $this->assertEquals("Her!", $contentParamsDecoded->cards[0]->answer); - $this->assertDatabaseHas("h5p_contents", ["id" => 1]); - $this->assertDatabaseHas("h5p_contents_metadata", ["id" => 1, 'content_id' => 1, "license" => "U"]); + $this->assertDatabaseHas("h5p_contents", ["id" => $content['id']]); + $this->assertDatabaseHas("h5p_contents_metadata", ['content_id' => $content['id'], "license" => "U"]); $request = new Request(); $request->replace([ @@ -145,7 +145,7 @@ public function createWithOutVersioningContent() $storedContent = $core->loadContent($content['id']); $updatedContent = $h5p->storeContent($request, $storedContent, "createContentUserId"); $this->assertNotFalse($updatedContent); - $this->assertEquals(1, $updatedContent['id']); + $this->assertEquals($content['id'], $updatedContent['id']); $this->assertEquals("Updated Test Title", $updatedContent['title']); $this->assertEquals("createContentUserId", $updatedContent['user_id']); $contentParamsDecoded = json_decode($updatedContent['params']); @@ -155,13 +155,13 @@ public function createWithOutVersioningContent() $this->assertEquals("Her!", $contentParamsDecoded->cards[0]->answer); $this->assertFileExists("{$this->getContentDirectory()}/{$updatedContent['id']}/images/image-5805bff7c5330.jpg"); - $h5pContent = H5PContent::find(1); + $h5pContent = H5PContent::find($content['id']); $this->assertEquals($h5pContent->id, $updatedContent['id']); $this->assertEquals("Deltittel", $h5pContent->title); - $this->assertDatabaseHas("h5p_contents", ["id" => 1]); - $this->assertDatabaseMissing("h5p_contents", ["id" => 2]); - $this->assertDatabaseHas("h5p_contents_metadata", ["id" => 1, 'content_id' => 1, 'license' => "BY"]); + $this->assertDatabaseCount("h5p_contents", 1); + $this->assertDatabaseHas("h5p_contents", ["id" => $content['id']]); + $this->assertDatabaseHas("h5p_contents_metadata", ['content_id' => $content['id'], 'license' => "BY"]); } #[Test] @@ -179,7 +179,7 @@ public function createWithVersioningContent() $h5p = app(h5p::class); $content = $h5p->storeContent($request, null, "createContentUserId"); $this->assertNotFalse($content); - $this->assertEquals(1, $content['id']); + $this->assertEquals(H5PContent::max('id'), $content['id']); $this->assertEquals("My Test Title", $content['title']); $this->assertEquals("createContentUserId", $content['user_id']); $this->assertFileExists("{$this->getContentDirectory()}/{$content['id']}/images/image-5805bff7c5330.jpg"); @@ -190,8 +190,8 @@ public function createWithVersioningContent() $this->assertEquals("Hvor er ørreten?", $contentParamsDecoded->cards[0]->text); $this->assertEquals("Her!", $contentParamsDecoded->cards[0]->answer); - $this->assertDatabaseHas("h5p_contents", ["id" => 1]); - $this->assertDatabaseHas("h5p_contents_metadata", ["id" => 1, 'content_id' => 1, "license" => "U"]); + $this->assertDatabaseHas("h5p_contents", ["id" => $content['id']]); + $this->assertDatabaseHas("h5p_contents_metadata", ['content_id' => $content['id'], "license" => "U"]); $request = Request::create('', parameters: [ 'library' => "H5P.Flashcards 1.1", @@ -204,7 +204,7 @@ public function createWithVersioningContent() $storedContent['useVersioning'] = true; $updatedContent = $h5p->storeContent($request, $storedContent, "createContentUserId"); $this->assertNotFalse($updatedContent); - $this->assertEquals(2, $updatedContent['id']); + $this->assertNotEquals($content['id'], $updatedContent['id']); $this->assertEquals("Updated Test Title", $updatedContent['title']); $this->assertEquals("createContentUserId", $updatedContent['user_id']); $contentParamsDecoded = json_decode($updatedContent['params']); @@ -214,10 +214,10 @@ public function createWithVersioningContent() $this->assertEquals("Her!", $contentParamsDecoded->cards[0]->answer); $this->assertFileExists("{$this->getContentDirectory()}/{$updatedContent['id']}/images/image-5805bff7c5330.jpg"); - $this->assertDatabaseHas("h5p_contents", ["id" => 1]); - $this->assertDatabaseHas("h5p_contents", ["id" => 2]); - $this->assertDatabaseMissing("h5p_contents", ["id" => 3]); - $this->assertDatabaseHas("h5p_contents_metadata", ["id" => 1, 'content_id' => 1, 'license' => "U"]); - $this->assertDatabaseHas("h5p_contents_metadata", ["id" => 2, 'content_id' => 2, 'license' => "BY"]); + $this->assertDatabaseCount("h5p_contents", 2); + $this->assertDatabaseHas("h5p_contents", ["id" => $content['id']]); + $this->assertDatabaseHas("h5p_contents", ["id" => $updatedContent['id']]); + $this->assertDatabaseHas("h5p_contents_metadata", ['content_id' => $content['id'], 'license' => "U"]); + $this->assertDatabaseHas("h5p_contents_metadata", ['content_id' => $updatedContent['id'], 'license' => "BY"]); } } diff --git a/sourcecode/apis/contentauthor/tests/Integration/MigrationsTest.php b/sourcecode/apis/contentauthor/tests/Integration/MigrationsTest.php new file mode 100644 index 0000000000..03fecc21e2 --- /dev/null +++ b/sourcecode/apis/contentauthor/tests/Integration/MigrationsTest.php @@ -0,0 +1,100 @@ +setUpConnection($driver); + + $this->artisan('migrate', ['--database' => $connection]) + ->assertSuccessful(); + + $this->artisan('migrate:status', ['--database' => $connection, '--pending' => true]) + ->expectsOutputToContain('No pending migrations') + ->assertSuccessful(); + + DB::purge($connection); + } + + /** + * @param 'mysql'|'pgsql' $driver + */ + #[DataProvider('provider_drivers')] + public function test_migrations_roll_back(string $driver): void + { + $connection = $this->setUpConnection($driver); + + $this->artisan('migrate', ['--database' => $connection]) + ->assertSuccessful(); + + $this->artisan('migrate:reset', ['--database' => $connection]) + ->assertSuccessful(); + + $this->assertSame( + ['migrations'], + Schema::connection($connection)->getTableListing(), + 'Tables left after rolling back all migrations', + ); + + DB::purge($connection); + } + + public static function provider_drivers(): Generator + { + yield 'mariadb' => ['mysql']; + yield 'postgres' => ['pgsql']; + } + + /** + * @param 'mysql'|'pgsql' $driver + */ + private function setUpConnection(string $driver): string + { + $config = match ($driver) { + 'mysql' => config('database.connections.mysql'), + 'pgsql' => [ + ...config('database.connections.pgsql'), + 'host' => env('DB_PGSQL_HOST'), + 'port' => env('DB_PGSQL_PORT'), + 'username' => env('DB_PGSQL_USERNAME'), + 'password' => env('DB_PGSQL_PASSWORD'), + ], + }; + + // Connect to a database that always exists, and create the one to migrate + $admin = "migrations_admin_$driver"; + config(["database.connections.$admin" => [ + ...$config, + 'database' => $driver === 'pgsql' ? 'postgres' : null, + ]]); + + DB::connection($admin)->statement('DROP DATABASE IF EXISTS ' . self::DATABASE . ($driver === 'pgsql' ? ' WITH (FORCE)' : '')); + DB::connection($admin)->statement('CREATE DATABASE ' . self::DATABASE); + DB::purge($admin); + + $connection = "migrations_test_$driver"; + config(["database.connections.$connection" => [...$config, 'database' => self::DATABASE]]); + + return $connection; + } +}