diff --git a/tests/Unit/AuthRepositoryTest.php b/tests/Unit/AuthRepositoryTest.php new file mode 100644 index 00000000..918a5001 --- /dev/null +++ b/tests/Unit/AuthRepositoryTest.php @@ -0,0 +1,78 @@ +serverBackup = $_SERVER; + $this->db = new TestDb(); + $this->db->exec( + 'CREATE TABLE access_codes (id INTEGER PRIMARY KEY, type INTEGER, code TEXT); + CREATE TABLE hmac_keys (id INTEGER PRIMARY KEY, key TEXT); + INSERT INTO access_codes (id, type, code) VALUES (1, 0, "alpha"), (2, 1, "beta"), (3, 0, "gamma"); + INSERT INTO hmac_keys (id, key) VALUES (7, "k7"), (8, "k8");' + ); + $GLOBALS['db'] = $this->db; + } + + protected function tearDown(): void { + $_SERVER = $this->serverBackup; + unset($GLOBALS['db']); + } + + public function testGetAllCodesKeyedById(): void { + $codes = AuthRepository::getAllCodes(); + $this->assertSame([1, 2, 3], array_keys($codes)); + $this->assertSame('beta', $codes[2]['code']); + } + + public function testGetAllCodesFilteredByType(): void { + $codes = AuthRepository::getAllCodes(0); + $this->assertSame([1, 3], array_keys($codes), 'only type 0'); + } + + public function testGetCodeByIdReturnsRowOrNull(): void { + $this->assertSame('alpha', AuthRepository::getCodeById(1)['code']); + $this->assertNull(AuthRepository::getCodeById(999)); + } + + public function testGetCurrentCodeReadsXcCodeServerParam(): void { + $_SERVER['XC_CODE'] = 'gamma'; + $this->assertSame('gamma', AuthRepository::getCurrentCode()); + + $row = AuthRepository::getCurrentCode(true); + $this->assertSame(3, (int) $row['id'], 'full row when $rInfo = true'); + + $_SERVER['XC_CODE'] = 'no_such_code'; + $this->assertNull(AuthRepository::getCurrentCode(true)); + } + + public function testGetAllHmacKeyedById(): void { + $keys = AuthRepository::getAllHMAC(); + $this->assertSame([7, 8], array_keys($keys)); + $this->assertSame('k7', $keys[7]['key']); + } + + public function testGetHmacByIdReturnsRowOrNull(): void { + $this->assertSame('k8', AuthRepository::getHMACById(8)['key']); + $this->assertNull(AuthRepository::getHMACById(999)); + } + + public function testDeleteHmacRemovesExistingAndReportsMissing(): void { + $this->assertTrue(AuthRepository::deleteHMAC(7)); + $this->assertNull(AuthRepository::getHMACById(7), 'gone after delete'); + $this->assertFalse(AuthRepository::deleteHMAC(7), 'already deleted'); + $this->assertFalse(AuthRepository::deleteHMAC(999)); + } +} diff --git a/tests/Unit/ModuleMigratorTest.php b/tests/Unit/ModuleMigratorTest.php new file mode 100644 index 00000000..1d84099f --- /dev/null +++ b/tests/Unit/ModuleMigratorTest.php @@ -0,0 +1,110 @@ +root = sys_get_temp_dir() . '/xcvm_mig_' . uniqid('', true); + mkdir($this->root . '/migrations', 0755, true); + $this->db = new TestDb(); + } + + protected function tearDown(): void { + $this->rrmdir($this->root); + } + + private function rrmdir(string $dir): void { + if (!is_dir($dir)) { + return; + } + foreach (scandir($dir) as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + $path = $dir . '/' . $entry; + is_dir($path) ? $this->rrmdir($path) : unlink($path); + } + rmdir($dir); + } + + private function writeMigration(string $version, string $sql): void { + file_put_contents($this->root . '/migrations/' . $version . '.sql', $sql); + } + + public function testInstallRunsMasterSchemaWhenPresent(): void { + file_put_contents( + $this->root . '/database.sql', + "CREATE TABLE mod_items (id INTEGER PRIMARY KEY, name TEXT);\nINSERT INTO mod_items (id, name) VALUES (1, 'seed');" + ); + + $ran = ModuleMigrator::install($this->root, $this->db, '2.0.0'); + + $this->assertSame(['database.sql'], $ran); + $this->db->query('SELECT name FROM mod_items WHERE id = 1;'); + $this->assertSame('seed', $this->db->get_col(), 'master schema applied'); + } + + public function testInstallFallsBackToReplayingDeltasWhenNoMaster(): void { + $this->writeMigration('1.0.0', 'CREATE TABLE mod_a (id INTEGER PRIMARY KEY);'); + $this->writeMigration('1.5.0', 'CREATE TABLE mod_b (id INTEGER PRIMARY KEY);'); + $this->writeMigration('3.0.0', 'CREATE TABLE mod_too_new (id INTEGER PRIMARY KEY);'); + + $ran = ModuleMigrator::install($this->root, $this->db, '2.0.0'); + + $this->assertSame(['1.0.0', '1.5.0'], $ran, 'only deltas <= target, ascending'); + } + + public function testUpAppliesOnlyDeltasInRange(): void { + $this->writeMigration('1.0.0', 'CREATE TABLE mod_a (id INTEGER PRIMARY KEY);'); + $this->writeMigration('1.1.0', 'CREATE TABLE mod_b (id INTEGER PRIMARY KEY);'); + $this->writeMigration('2.0.0', 'CREATE TABLE mod_c (id INTEGER PRIMARY KEY);'); + + $ran = ModuleMigrator::up($this->root, $this->db, '1.0.0', '2.0.0'); + + $this->assertSame(['1.1.0', '2.0.0'], $ran, 'exclusive of $from, inclusive of $to'); + } + + public function testUninstallRunsDropFileOrNoOps(): void { + $this->assertSame([], ModuleMigrator::uninstall($this->root, $this->db), 'no teardown file'); + + file_put_contents($this->root . '/database_drop.sql', 'DROP TABLE IF EXISTS whatever;'); + $this->assertSame(['database_drop.sql'], ModuleMigrator::uninstall($this->root, $this->db)); + } + + public function testHasReportsAnySchemaPresence(): void { + $this->assertFalse(ModuleMigrator::has($this->root), 'empty migrations dir, no master/drop'); + $this->writeMigration('1.0.0', 'CREATE TABLE x (id INTEGER);'); + $this->assertTrue(ModuleMigrator::has($this->root)); + } + + public function testDiscoverIgnoresNonSemverAndStripsComments(): void { + // Non-semver file must be ignored (else it would run and fail here). + file_put_contents($this->root . '/migrations/notes.sql', 'this is not valid sql at all'); + $this->writeMigration('1.0.0', "-- create the table\nCREATE TABLE mod_c (id INTEGER PRIMARY KEY);\n-- trailing comment"); + + $ran = ModuleMigrator::install($this->root, $this->db, '1.0.0'); + + $this->assertSame(['1.0.0'], $ran); + $this->db->query('SELECT COUNT(*) FROM mod_c;'); + $this->assertSame('0', (string) $this->db->get_col(), 'commented statement ran cleanly'); + } + + public function testRunFilePropagatesFailure(): void { + $this->writeMigration('1.0.0', 'THIS IS NOT VALID SQL;'); + $this->expectException(\Exception::class); + ModuleMigrator::up($this->root, $this->db, null, '1.0.0'); + } +} diff --git a/tests/Unit/SettingsRepositoryTest.php b/tests/Unit/SettingsRepositoryTest.php new file mode 100644 index 00000000..602e1ee9 --- /dev/null +++ b/tests/Unit/SettingsRepositoryTest.php @@ -0,0 +1,93 @@ +db = new TestDb(); + $this->db->exec('CREATE TABLE settings (id INTEGER PRIMARY KEY, allow_countries TEXT, allowed_stb_types TEXT, stalker_lock_images TEXT, bouquet_name TEXT, api_ips TEXT, shared_mount_prefixes TEXT);'); + $GLOBALS['db'] = $this->db; + } + + protected function tearDown(): void { + unset($GLOBALS['db']); + foreach (glob(rtrim(CACHE_TMP_PATH, '/') . '/*') ?: [] as $f) { + if (is_file($f)) { + unlink($f); + } + } + } + + private function seed(array $row): void { + // Production settings columns are NOT NULL DEFAULT '', so unseeded fields + // default to '' here (not null) to mirror the real row shape. + $this->db->query( + 'INSERT INTO settings (id, allow_countries, allowed_stb_types, stalker_lock_images, bouquet_name, api_ips, shared_mount_prefixes) VALUES (1, ?, ?, ?, ?, ?, ?);', + $row['allow_countries'] ?? '', + $row['allowed_stb_types'] ?? '', + $row['stalker_lock_images'] ?? '', + $row['bouquet_name'] ?? '', + $row['api_ips'] ?? '', + $row['shared_mount_prefixes'] ?? '' + ); + } + + public function testDecodesJsonAndCsvFields(): void { + $this->seed([ + 'allow_countries' => '["US","GB"]', + 'allowed_stb_types' => '["MAG250"," ",""]', + 'stalker_lock_images' => 'null', + 'bouquet_name' => 'My Bouquet', + 'api_ips' => '1.2.3.4,5.6.7.8', + 'shared_mount_prefixes' => '["/mnt/a"," /mnt/b "]', + ]); + + $out = SettingsRepository::getAll(true); + + $this->assertSame(['US', 'GB'], $out['allow_countries']); + $this->assertSame(['mag250'], $out['allowed_stb_types'], 'lowercased, trimmed, blanks dropped'); + $this->assertNull($out['stalker_lock_images']); + $this->assertSame('My_Bouquet', $out['bouquet_name'], 'spaces become underscores'); + $this->assertSame(['1.2.3.4', '5.6.7.8'], $out['api_ips']); + $this->assertSame(['/mnt/a', '/mnt/b'], $out['shared_mount_prefixes']); + } + + public function testEmptyListFieldsCollapseToEmptyArrays(): void { + $this->seed([ + 'allowed_stb_types' => '[""]', + 'api_ips' => '', + 'shared_mount_prefixes' => '', + ]); + + $out = SettingsRepository::getAll(true); + + $this->assertSame([], $out['allowed_stb_types'], 'an "empty" multiselect ([""]) collapses'); + $this->assertSame([], $out['api_ips']); + $this->assertSame([], $out['shared_mount_prefixes']); + } + + public function testSharedMountPrefixesFallsBackToLegacyCsv(): void { + $this->seed(['shared_mount_prefixes' => 'legacy1, legacy2']); + + $out = SettingsRepository::getAll(true); + + $this->assertSame(['legacy1', 'legacy2'], $out['shared_mount_prefixes'], 'non-JSON CSV self-heals'); + } +}