toBe(11); expect(replication_manager::mysqlGtidCoveragePercent($source, $executed))->toBe(72.73); }); it('reports empty source GTID sets as caught up', function (): void { expect(replication_manager::mysqlGtidCoveragePercent('', ''))->toBe(100.0); }); it('computes Redis offset percentages safely', function (): void { expect(replication_manager::redisOffsetPercent(1000, 750))->toBe(75.0); expect(replication_manager::redisOffsetPercent(0, 0))->toBe(100.0); expect(replication_manager::redisOffsetPercent(1000, 1250))->toBe(100.0); }); it('computes MariaDB GTID coverage by domain sequence', function (): void { $source = '0-1-10,1-1-20'; $replica = '0-2-8,1-3-20'; expect(replication_manager::mariadbGtidCoveragePercent($source, $replica))->toBe(93.33); expect(replication_manager::mariadbGtidCoveragePercent('', ''))->toBe(100.0); }); it('normalizes public replication kind aliases', function (): void { expect(replication_manager::normalizeKind('databases'))->toBe('database'); expect(replication_manager::normalizeKind('mysql'))->toBe('database'); expect(replication_manager::normalizeKind('redis'))->toBe('redis'); expect(replication_manager::normalizeKind('minio'))->toBe('minio'); expect(replication_manager::normalizeKind('s3'))->toBe('minio'); expect(replication_manager::normalizeKind('object-storage'))->toBe('minio'); }); it('generates replication-ready MariaDB compose templates without embedding secrets', function (): void { $template = replication_manager::composeTemplate([ 'kind' => 'database', 'role' => 'replica', 'service_name' => 'MariaDB Replica 2', 'database' => 'nnks_db', 'username' => 'nnks_db_user', 'host_port' => 5433, 'server_id' => 2, ]); expect($template['kind'])->toBe('database'); expect($template['role'])->toBe('replica'); expect($template['service_name'])->toBe('mariadb-replica-2'); expect($template['compose'])->toContain('image: "mariadb:11"'); expect($template['compose'])->toContain('"--server-id=2"'); expect($template['compose'])->toContain('"--log-bin=/var/lib/mysql/mariadb-bin"'); expect($template['compose'])->toContain('"--binlog-format=ROW"'); expect($template['compose'])->toContain('"--gtid-strict-mode=ON"'); expect($template['compose'])->toContain('"--read-only=ON"'); expect($template['compose'])->toContain('"--replicate-ignore-table=nnks_db.logs"'); expect($template['compose'])->toContain('"--replicate-ignore-table=nnks_db.edge_gateway_log_entries"'); expect($template['compose'])->toContain('"--replicate-ignore-table=nnks_db.replication_status_snapshots"'); expect($template['compose'])->toContain('"5433:3306"'); expect($template['compose'])->toContain('mariadb-replica-2-seed'); expect($template['compose'])->toContain('MARIADB_PRIMARY_ADMIN_PASSWORD'); expect($template['compose'])->toContain('mariadb-dump --host="$MARIADB_PRIMARY_HOST"'); expect($template['compose'])->toContain('--ignore-table="$MARIADB_SEED_DATABASE.logs"'); expect($template['compose'])->toContain('--ignore-table="$MARIADB_SEED_DATABASE.edge_gateway_log_entries"'); expect($template['compose'])->toContain('--ignore-table="$MARIADB_SEED_DATABASE.replication_status_snapshots"'); expect($template['compose'])->toContain('--no-data "$MARIADB_SEED_DATABASE" "$table"'); expect($template['compose'])->toContain('${MARIADB_ROOT_PASSWORD:?set MARIADB_ROOT_PASSWORD}'); expect($template['compose'])->not->toContain(''); expect($template['env'])->toMatch('/MARIADB_ROOT_PASSWORD=[A-Za-z0-9_-]{32}/'); expect($template['env'])->toMatch('/MARIADB_PASSWORD=[A-Za-z0-9_-]{32}/'); expect($template['env'])->toContain('MARIADB_PRIMARY_ADMIN_PASSWORD='); expect($template['credentials']['password'])->toMatch('/^[A-Za-z0-9_-]{32}$/'); expect($template['credentials']['admin_password'])->toMatch('/^[A-Za-z0-9_-]{32}$/'); expect($template['credentials']['port'])->toBe(5433); expect($template['credentials']['allow_preseeded_replica'])->toBeTrue(); expect($template['seed_command'])->toContain('mariadb-dump'); expect($template['seed_command'])->toContain('--gtid'); expect($template['seed_command'])->toContain('--master-data=2'); expect($template['seed_command'])->toContain('--ignore-table=\'nnks_db.logs\''); expect($template['seed_command'])->toContain('--ignore-table=\'nnks_db.edge_gateway_log_entries\''); expect($template['seed_command'])->toContain('--ignore-table=\'nnks_db.replication_status_snapshots\''); }); it('detects missing database tables before provisioning a preseeded replica', function (): void { expect(replication_manager::missingDatabaseTables( ['customers', 'edge_gateways', 'orders'], ['customers', 'orders'] ))->toBe(['edge_gateways']); }); it('seeds MariaDB replicas in place instead of requiring container recreation', function (): void { $content = file_get_contents(app_path('classes/replication_manager.php')); expect($content)->toContain('advanceMariaDbReplicaSeed($operationId, $primary, $host, $target)'); expect($content)->toContain('MARIADB_SEED_BATCH_ROWS'); expect($content)->toContain('MARIADB_SEED_STEP_SECONDS'); expect($content)->toContain('activeOperationId($kind, $id, \'provision\')'); expect($content)->toContain('updateOperationProgress($operationId, $progress, $message, $context)'); expect($content)->toContain("application_write_freeze::freeze('MariaDB replica seed is copying data.'"); expect($content)->toContain('DROP DATABASE IF EXISTS'); expect($content)->toContain('CREATE DATABASE '); expect($content)->toContain('SHOW CREATE TABLE'); expect($content)->toContain('SET GLOBAL gtid_slave_pos'); expect($content)->toContain('databasePrimaryKeyColumnNames($source, $primaryDatabase, $tableName)'); expect($content)->toContain('$target->begin_transaction()'); expect($content)->toContain("'connect_without_database' => \$usePreseededReplica"); }); it('keeps operational log tables schema-only during MariaDB seeding and replication', function (): void { $content = file_get_contents(app_path('classes/replication_manager.php')); expect($content)->toContain('MARIADB_SCHEMA_ONLY_TABLES'); expect($content)->toContain("'logs'"); expect($content)->toContain("'edge_gateway_log_entries'"); expect($content)->toContain("'replication_status_snapshots'"); expect($content)->toContain("'replication_operations'"); expect($content)->toContain("'replication_audit_logs'"); expect($content)->toContain("'skip_data' => \$skipData"); expect($content)->toContain('createMariaDbReplicaTable('); expect($content)->toContain('SET GLOBAL replicate_ignore_table'); expect($content)->toContain('--replicate-ignore-table='); expect($content)->toContain('mariaDbSchemaOnlyDumpIgnoreArgs'); expect($content)->toContain('mariaDbSchemaOnlySeedCommandIgnoreArgs'); expect($content)->toContain('databaseSchemaOnlyTablesWithRows'); expect($content)->toContain('schemaOnlyTablesContainRowsBlocker'); expect($content)->toContain('RESET SLAVE ALL'); expect($content)->toContain('mariaDbSeedContextRequiresFilterReset($context)'); }); it('allows failed replicas to be removed without allowing primary or healthy replica removal', function (): void { expect(replication_manager::replicationHostCanBeRemoved(['role' => 'primary', 'status' => 'ok']))->toBeFalse(); expect(replication_manager::replicationHostCanBeRemoved(['role' => 'inactive', 'status' => 'inactive']))->toBeTrue(); expect(replication_manager::replicationHostCanBeRemoved(['role' => 'replica', 'status' => 'degraded']))->toBeTrue(); expect(replication_manager::replicationHostCanBeRemoved(['role' => 'replica', 'status' => 'ok']))->toBeFalse(); }); it('generates Redis replica compose templates with primary connection placeholders', function (): void { $template = replication_manager::composeTemplate([ 'kind' => 'redis', 'role' => 'replica', 'service_name' => 'Redis Replica', 'host_port' => 6380, 'primary_host' => 'redis-primary.internal', 'primary_port' => 6379, ]); expect($template['kind'])->toBe('redis'); expect($template['compose'])->toContain('image: "redis:7"'); expect($template['compose'])->toContain('REDIS_PASSWORD: "${REDIS_PASSWORD:?set REDIS_PASSWORD}"'); expect($template['compose'])->toContain('REDIS_PRIMARY_HOST: "${REDIS_PRIMARY_HOST:?set REDIS_PRIMARY_HOST}"'); expect($template['compose'])->toContain('REDIS_PRIMARY_PORT: "${REDIS_PRIMARY_PORT:-6379}"'); expect($template['compose'])->toContain('${REDIS_PRIMARY_PASSWORD:?set REDIS_PRIMARY_PASSWORD}'); expect($template['compose'])->toContain('REDIS_PRIMARY_USERNAME: "${REDIS_PRIMARY_USERNAME:-}"'); expect($template['compose'])->toContain('if [ ! -f /data/redis.conf ]; then'); expect($template['compose'])->toContain('> /data/redis.conf'); expect($template['compose'])->toContain('exec redis-server /data/redis.conf'); expect($template['compose'])->toContain('echo "replicaof $$REDIS_PRIMARY_HOST $${REDIS_PRIMARY_PORT:-6379}"'); expect($template['compose'])->toContain('echo "masterauth $$REDIS_PRIMARY_PASSWORD"'); expect($template['compose'])->toContain('echo "masteruser $$REDIS_PRIMARY_USERNAME"'); expect($template['compose'])->toContain('redis-cli --no-auth-warning -a \"$${REDIS_PASSWORD}\" ping | grep PONG'); expect($template['compose'])->not->toContain($template['credentials']['password']); expect($template['env'])->toMatch('/REDIS_PASSWORD=[A-Za-z0-9_-]{32}/'); expect($template['env'])->toContain('REDIS_PRIMARY_HOST=redis-primary.internal'); expect($template['env'])->toContain('REDIS_PRIMARY_PORT=6379'); expect($template['env'])->toContain("REDIS_PRIMARY_PASSWORD=\n"); expect($template['env'])->toContain("REDIS_PRIMARY_USERNAME=\n"); expect($template['credentials']['password'])->toMatch('/^[A-Za-z0-9_-]{32}$/'); }); it('generates MinIO replica compose templates without embedding secrets', function (): void { $template = replication_manager::composeTemplate([ 'kind' => 'minio', 'role' => 'replica', 'service_name' => 'MinIO Replica 1', 'host_port' => 9010, 'console_port' => 9011, 'buckets' => ['attachments', 'uploads'], ]); expect($template['kind'])->toBe('minio'); expect($template['engine'])->toBe('minio'); expect($template['service_name'])->toBe('minio-replica-1'); expect($template['host_port'])->toBe(9010); expect($template['console_port'])->toBe(9011); expect($template['compose'])->toContain('image: "minio/minio:latest"'); expect($template['compose'])->toContain('MINIO_ROOT_USER: "${MINIO_ROOT_USER:?set MINIO_ROOT_USER}"'); expect($template['compose'])->toContain('MINIO_ROOT_PASSWORD: "${MINIO_ROOT_PASSWORD:?set MINIO_ROOT_PASSWORD}"'); expect($template['compose'])->toContain('"9010:9000"'); expect($template['compose'])->toContain('"9011:9001"'); expect($template['compose'])->toContain('mc mb --ignore-existing'); expect($template['compose'])->toContain('mc version enable'); expect($template['compose'])->toContain('MINIO_PRIMARY_ENDPOINT'); expect($template['compose'])->not->toContain($template['credentials']['password']); expect($template['env'])->toMatch('/MINIO_ROOT_PASSWORD=[A-Za-z0-9_-]{32}/'); expect($template['env'])->toContain('MINIO_BUCKETS=attachments,uploads'); expect($template['env'])->toContain('MINIO_PRIMARY_ENDPOINT='); expect($template['credentials']['scheme'])->toBe('http'); expect($template['credentials']['buckets'])->toBe(['attachments', 'uploads']); expect($template['credentials']['space_headroom_percent'])->toBe(20.0); }); it('computes MinIO free-space and catch-up math safely', function (): void { expect(replication_manager::minioRequiredFreeBytes(1000))->toBe(1200); expect(replication_manager::minioByteReplicationPercent(1000, 750))->toBe(75.0); expect(replication_manager::minioByteReplicationPercent(0, 0))->toBe(100.0); expect(replication_manager::minioSpaceBlockers(1199, 1200))->toContain('MinIO target does not have enough free space. Required 1200 bytes, available 1199 bytes.'); expect(replication_manager::minioSpaceBlockers(null, 1200))->toContain('MinIO target free space could not be determined.'); expect(replication_manager::minioAvailableBytesFromAdminInfo([ 'servers' => [ ['drives' => [['availableSpace' => 4096]]], ], ]))->toBe(4096); expect(replication_manager::normalizeMinioBuckets('Attachments, uploads backups'))->toBe(['attachments', 'uploads', 'backups']); }); it('wires MinIO replication through routes and bootstrap snapshots', function (): void { $manager = file_get_contents(app_path('classes/replication_manager.php')); $routes = file_get_contents(app_path('routes/superuserReplicationRoute.php')); $openapi = file_get_contents(app_path('openapi.yaml')); expect($manager)->toContain('private const KIND_MINIO'); expect($manager)->toContain('provisionMinioHost($host, $operationId)'); expect($manager)->toContain('promoteMinioHost($host)'); expect($manager)->toContain('testMinioHost($host)'); expect($manager)->toContain('minioTargetFreeBytes($host)'); expect($manager)->toContain('minioRequiredFreeBytes'); expect($manager)->toContain("'minio' => ["); expect($routes)->toContain("/superuser/replication/minio"); expect($openapi)->toContain('enum: [database, redis, minio]'); expect($openapi)->toContain('endpoint:'); expect($openapi)->toContain('space_headroom_percent:'); }); it('provisions Redis replicas after a connectivity-only preflight and reports sync progress', function (): void { $content = file_get_contents(app_path('classes/replication_manager.php')); expect($content)->toContain('provisionRedisHost($host, $operationId)'); expect($content)->toContain("testRedisHost(array_merge(\$host, ['test_connectivity_only' => true]))"); expect($content)->toContain("executeRaw(['REPLICAOF', (string)\$primary['host'], (string)\$primary['port']])"); expect($content)->toContain("executeRaw(['CONFIG', 'REWRITE'])"); expect($content)->toContain('Redis replication was configured; waiting for the replica to catch up.'); expect($content)->toContain('$onlySyncBlockers'); expect($content)->toContain("'status' => 'running'"); expect($content)->toContain('Redis replica is syncing from the primary.'); }); it('keeps Redis promotion caught-up, durable, and metadata-safe', function (): void { $content = file_get_contents(app_path('classes/replication_manager.php')); expect($content)->toContain("application_write_freeze::freeze('Replication promotion in progress.'"); expect($content)->toContain("if (\$status['blockers'] !== [] || (float)\$status['replication_percent'] < 100.0)"); expect($content)->toContain("executeRaw(['REPLICAOF', 'NO', 'ONE'])"); expect($content)->toContain("executeRaw(['CONFIG', 'REWRITE'])"); expect($content)->toContain('switchPrimary(self::KIND_REDIS'); expect($content)->toContain('writeBootstrapSnapshot()'); }); it('does not require replica SQL threads before database provisioning configures them', function (): void { $content = file_get_contents(app_path('classes/replication_manager.php')); expect($content)->toContain("'test_connectivity_only' => true"); expect($content)->toContain("'connect_without_database' => \$usePreseededReplica"); expect($content)->toContain("'healthy' => \$status['blockers'] === []"); expect($content)->toContain("'message' => \$targetEngine === 'mariadb'"); expect($content)->toContain('Database replica status is not configured.'); expect($content)->toContain('Database replication IO thread is not running.'); expect($content)->toContain('Database replication SQL thread is not running.'); expect($content)->toContain("if (\$status !== [])"); }); it('keeps replication operation progress schema idempotent for existing installs', function (): void { $content = file_get_contents(app_path('classes/replication_schema_bootstrap.php')); expect($content)->toContain("ensureColumn('replication_operations', 'progress_percent'"); expect($content)->toContain("ensureColumn('replication_operations', 'message'"); expect($content)->toContain("ensureColumn('replication_operations', 'context_json'"); }); it('creates the generated replication user on the primary during provisioning', function (): void { $content = file_get_contents(app_path('classes/replication_manager.php')); expect($content)->toContain('ensureDatabaseReplicationUser($primary, $replicationUser, $replicationPassword)'); expect($content)->toContain('GRANT REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO'); }); it('supports MariaDB prerequisites without requiring Oracle MySQL variables', function (): void { $blockers = replication_manager::databasePrerequisiteBlockers([ 'server_version' => '11.8.6-MariaDB-ubu2404', 'log_bin' => 'ON', 'server_id' => '12', 'gtid_binlog_pos' => '0-12-42', ]); expect($blockers)->toBe([]); $quietServerBlockers = replication_manager::databasePrerequisiteBlockers([ 'server_version' => '11.8.6-MariaDB-ubu2404', 'log_bin' => 'ON', 'server_id' => '12', 'gtid_current_pos' => '', ]); expect($quietServerBlockers)->toBe([]); }); it('reports MariaDB-specific blockers when GTID or binary logging prerequisites are missing', function (): void { $blockers = replication_manager::databasePrerequisiteBlockers([ 'server_version' => '11.8.6-MariaDB-ubu2404', 'log_bin' => 'OFF', 'server_id' => '12', ]); expect($blockers)->toContain('MariaDB binary logging must be enabled.'); expect($blockers)->toContain('MariaDB GTID position must be available.'); expect($blockers)->not->toContain('Oracle MySQL 8.x is required for managed replication. Current server reports 11.8.6-MariaDB-ubu2404.'); });