. * */ namespace Ampache\Repository; use Ampache\Config\AmpConfig; use Ampache\Module\Authorization\Check\PrivilegeCheckerInterface; use Ampache\Module\Database\DatabaseConnectionInterface; use Ampache\Module\Database\Exception\QueryFailedException; use Ampache\Module\Database\RandomIdSamplerInterface; use Ampache\Module\System\LegacyLogger; use Ampache\Repository\Model\Album; use Ampache\Repository\Model\AlbumFieldEnum; use PDOStatement; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; use SEEC\PhpUnit\Helper\ConsecutiveParams; class AlbumRepositoryTest extends TestCase { use ConsecutiveParams; private DatabaseConnectionInterface&MockObject $connection; private LoggerInterface&MockObject $logger; private RandomIdSamplerInterface&MockObject $randomIdSampler; private AlbumRepository $subject; /** * `Album::build_cache()` fills the artist list for a whole page and `get_parent_ids()` prefers it over a read, * so a map write that leaves it in place has every later read in the same request answering with stale artists */ public function testAddAlbumMapForgetsTheCachedArtistList(): void { Album::add_to_cache('album_artists', 666, [1, 2, 3]); self::assertTrue(Album::is_cached('album_artists', 666)); $this->connection->expects(static::once()) ->method('query'); $this->subject->addAlbumMap(666, 'album', 42); self::assertFalse(Album::is_cached('album_artists', 666)); } public function testAddAlbumMapInsertsIgnoringDuplicates(): void { $this->connection->expects(static::once()) ->method('query') ->with( 'INSERT IGNORE INTO `album_map` (`album_id`, `object_type`, `object_id`) VALUES (?, ?, ?);', [666, 'album', 42] ); $this->subject->addAlbumMap(666, 'album', 42); } /** * A song whose album row is gone used to contribute a NULL id through the outer join, and it sorts first. * `while ($albumId = $result->fetchColumn())` reads that as the end of the result, so a single orphaned * song emptied the whole album list -- which is what Subsonic and UPnP browse. */ public function testAnOrphanedSongCannotContributeAnAlbumId(): void { $this->connection->expects(static::once()) ->method('query') ->with(self::stringContains('FROM `song` INNER JOIN `album` ON `album`.`id` = `song`.`album`')) ->willReturn($this->createMock(PDOStatement::class)); $this->subject->getIdsByCatalogs([1]); } public function testCollectGarbageDeletes(): void { $this->connection->expects(static::exactly(7)) ->method('query') ->with( ...self::withConsecutive( ["DELETE FROM `album_map` WHERE `object_type` = 'album' AND `album_id` IN (SELECT `id` FROM `album` WHERE `album_artist` IS NULL)"], ['DELETE FROM `album_map` WHERE `object_id` NOT IN (SELECT `id` FROM `artist`)'], ['DELETE FROM `album_map` WHERE `album_map`.`album_id` NOT IN (SELECT DISTINCT `song`.`album` FROM `song`)'], ["DELETE FROM `album_map` WHERE `album_map`.`album_id` IN (SELECT `album_id` FROM (SELECT DISTINCT `album_map`.`album_id` FROM `album_map` LEFT JOIN `artist_map` ON `artist_map`.`object_type` = `album_map`.`object_type` AND `artist_map`.`artist_id` = `album_map`.`object_id` AND `artist_map`.`object_id` = `album_map`.`album_id` WHERE `artist_map`.`artist_id` IS NULL AND `album_map`.`object_type` = 'album') AS `null_album`)"], ['DELETE FROM `album` WHERE `album`.`id` NOT IN (SELECT DISTINCT `song`.`album` FROM `song`) AND `album`.`id` NOT IN (SELECT DISTINCT `album_id` FROM `album_map`)'], ['DELETE FROM `album_disk` WHERE `album_id` NOT IN (SELECT `id` FROM `album`)'], ["SELECT `album_disk`.`id` FROM `album_disk` LEFT JOIN `album` ON `album`.`id` = `album_disk`.`album_id` WHERE NOT (`album`.`catalog` = 0 AND `album_disk`.`catalog` = 0) AND NOT EXISTS (SELECT 1 FROM `song` WHERE `song`.`album` = `album_disk`.`album_id` AND `song`.`disk` = `album_disk`.`disk` AND `song`.`catalog` = `album_disk`.`catalog`);"], ) ); $this->subject->collectGarbage(); } public function testCollectOrphanedAlbumMapsTouchesOnlyTheMapTable(): void { $calls = []; $this->connection->expects(static::exactly(4)) ->method('query') ->willReturnCallback(function (string $sql) use (&$calls): PDOStatement { $calls[] = $sql; return $this->createMock(PDOStatement::class); }); $this->subject->collectOrphanedAlbumMaps(); foreach ($calls as $sql) { self::assertStringStartsWith('DELETE FROM `album_map`', $sql); } } public function testCreateReturnsTheNewId(): void { $this->connection->expects(static::once()) ->method('query') ->with( 'INSERT INTO `album` (`name`, `prefix`, `year`, `mbid`, `mbid_group`, `release_type`, `release_status`, `album_artist`, `original_year`, `barcode`, `catalog_number`, `version`, `catalog`, `addition_time`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', ['some-album', 'The', 1999, null, null, null, null, 42, null, null, null, null, 7, 123456] ); $this->connection->expects(static::once()) ->method('getLastInsertedId') ->willReturn(666); self::assertSame(666, $this->subject->create($this->createProperties(), 123456)); } public function testCreateReturnsZeroWhenTheInsertFailed(): void { // the caller reads 0 as "no album" and carries on, so the exception must not escape $this->connection->expects(static::once()) ->method('query') ->willThrowException(new QueryFailedException('some-error')); self::assertSame(0, $this->subject->create($this->createProperties(), 123456)); } public function testCreateStillWritesTheScannedNameAndYearWhenBothAreDroppedFromGroupingConfig(): void { // name/year aren't nullable, so dropping them from grouping doesn't null them out on create() - the // scanned value they're written with is a better default than a placeholder like 0 AmpConfig::set('album_grouping_fields', 'album_artist', true); try { $this->connection->expects(static::once()) ->method('query') ->with( 'INSERT INTO `album` (`name`, `prefix`, `year`, `mbid`, `mbid_group`, `release_type`, `release_status`, `album_artist`, `original_year`, `barcode`, `catalog_number`, `version`, `catalog`, `addition_time`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', ['some-album', null, 1999, null, null, null, null, 42, null, null, null, null, 7, 123456] ); $this->connection->expects(static::once()) ->method('getLastInsertedId') ->willReturn(666); self::assertSame(666, $this->subject->create($this->createProperties(), 123456)); } finally { AmpConfig::set('album_grouping_fields', null, true); } } public function testCreateWritesNullForFieldsDroppedFromGroupingConfig(): void { // a column dropped from album_grouping_fields is never stored at all, rather than fixing the new // album to whichever song happened to create it AmpConfig::set('album_grouping_fields', 'name,year,prefix,mbid,mbid_group,album_artist,release_type,release_status,original_year,catalog_number,version', true); try { $this->connection->expects(static::once()) ->method('query') ->with( 'INSERT INTO `album` (`name`, `prefix`, `year`, `mbid`, `mbid_group`, `release_type`, `release_status`, `album_artist`, `original_year`, `barcode`, `catalog_number`, `version`, `catalog`, `addition_time`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', ['some-album', 'The', 1999, null, null, null, null, 42, null, null, null, null, 7, 123456] ); $this->connection->expects(static::once()) ->method('getLastInsertedId') ->willReturn(666); $properties = $this->createProperties(); $properties['barcode'] = '111'; self::assertSame(666, $this->subject->create($properties, 123456)); } finally { AmpConfig::set('album_grouping_fields', null, true); } } public function testDeleteDeletes(): void { $album = $this->createMock(Album::class); $albumId = 666; $album->method('getId') ->willReturn($albumId); $calls = []; $pdoStub = $this->createMock(\PDOStatement::class); $this->connection->expects(static::exactly(2)) ->method('query') ->willReturnCallback(function (string $sql, array $params = []) use (&$calls, $pdoStub): \PDOStatement { $calls[] = [$sql, $params]; return $pdoStub; }); $this->subject->delete($album); self::assertSame( [ ['DELETE FROM `album` WHERE `id` = ?', [$albumId]], ['DELETE FROM `album_disk` WHERE `album_id` = ?', [$albumId]], ], $calls, ); } public function testDeleteEmptyCarriesOnAfterAFailedStatement(): void { $calls = []; $this->connection->expects(static::exactly(3)) ->method('query') ->willReturnCallback(function (string $sql) use (&$calls): PDOStatement { $calls[] = $sql; if (count($calls) === 1) { throw new QueryFailedException('nope'); } return $this->createMock(PDOStatement::class); }); $this->logger->expects(static::once()) ->method('warning'); $this->subject->deleteEmpty(666); self::assertSame( [ 'DELETE FROM `album` WHERE `id` = ?', 'DELETE FROM `album_map` WHERE `album_id` = ?', "DELETE FROM `artist_map` WHERE `object_id` = ? AND `object_type` = 'album'", ], $calls ); } public function testFindByPropertiesCanDisableNameAndYearMatching(): void { // every column is configurable, including name/year - the admin can choose a value that merges albums // a stricter set would have kept apart, and nothing here stops that AmpConfig::set('album_grouping_fields', 'album_artist', true); try { $this->connection->expects(static::once()) ->method('fetchOne') ->with( 'SELECT DISTINCT(`album`.`id`) AS `id` FROM `album` WHERE `album`.`album_artist` = ? AND `album`.`catalog` = ?;', [42, 7] ) ->willReturn('666'); self::assertSame(666, $this->subject->findByProperties($this->createProperties())); } finally { AmpConfig::set('album_grouping_fields', null, true); } } public function testFindByPropertiesDoesNotLogWhenNothingIsDropped(): void { $this->connection->method('fetchOne')->willReturn('666'); $this->logger->expects(static::never())->method('warning'); self::assertSame(666, $this->subject->findByProperties($this->createProperties())); } public function testFindByPropertiesDropsYearEntirelyWhenOmittedFromConfig(): void { // year is droppable like any other column (only name/year's non-nullability is special-cased in // create(), not their eligibility for findByProperties()'s grouping set); dropping it merges albums // across different years as long as everything else configured still matches AmpConfig::set('album_grouping_fields', 'name,prefix,mbid,mbid_group,album_artist,release_type,release_status,original_year,barcode,catalog_number,version', true); try { $this->connection->expects(static::once()) ->method('fetchOne') ->with( "SELECT DISTINCT(`album`.`id`) AS `id` FROM `album` WHERE (`album`.`name` = ? OR LTRIM(CONCAT(COALESCE(`album`.`prefix`, ''), ' ', `album`.`name`)) = ?) AND `album`.`prefix` = ? AND `album`.`mbid` IS NULL AND `album`.`mbid_group` IS NULL AND `album`.`album_artist` = ? AND `album`.`release_type` IS NULL AND `album`.`release_status` IS NULL AND `album`.`original_year` IS NULL AND `album`.`barcode` IS NULL AND `album`.`catalog_number` IS NULL AND `album`.`version` IS NULL AND `album`.`catalog` = ?;", ['some-album', 'some-album', 'The', 42, 7] ) ->willReturn('666'); self::assertSame(666, $this->subject->findByProperties($this->createProperties())); } finally { AmpConfig::set('album_grouping_fields', null, true); } } public function testFindByPropertiesFallsBackToAllColumnsWhenConfigIsEmpty(): void { // an empty configured value keeps today's full-field behavior rather than matching nothing AmpConfig::set('album_grouping_fields', '', true); try { $this->connection->expects(static::once()) ->method('fetchOne') ->with( "SELECT DISTINCT(`album`.`id`) AS `id` FROM `album` WHERE (`album`.`name` = ? OR LTRIM(CONCAT(COALESCE(`album`.`prefix`, ''), ' ', `album`.`name`)) = ?) AND `album`.`year` = ? AND `album`.`prefix` = ? AND `album`.`mbid` IS NULL AND `album`.`mbid_group` IS NULL AND `album`.`album_artist` = ? AND `album`.`release_type` IS NULL AND `album`.`release_status` IS NULL AND `album`.`original_year` IS NULL AND `album`.`barcode` IS NULL AND `album`.`catalog_number` IS NULL AND `album`.`version` IS NULL AND `album`.`catalog` = ?;", ['some-album', 'some-album', 1999, 'The', 42, 7] ) ->willReturn('666'); self::assertSame(666, $this->subject->findByProperties($this->createProperties())); } finally { AmpConfig::set('album_grouping_fields', null, true); } } public function testFindByPropertiesIgnoresUnknownConfiguredColumns(): void { // config content reaches a SQL identifier, so an unrecognised name must be dropped, not just left unbound AmpConfig::set('album_grouping_fields', 'name, year, album_artist, nonexistent_column', true); try { $this->connection->expects(static::once()) ->method('fetchOne') ->with( "SELECT DISTINCT(`album`.`id`) AS `id` FROM `album` WHERE (`album`.`name` = ? OR LTRIM(CONCAT(COALESCE(`album`.`prefix`, ''), ' ', `album`.`name`)) = ?) AND `album`.`year` = ? AND `album`.`album_artist` = ? AND `album`.`catalog` = ?;", ['some-album', 'some-album', 1999, 42, 7] ) ->willReturn('666'); self::assertSame(666, $this->subject->findByProperties($this->createProperties())); } finally { AmpConfig::set('album_grouping_fields', null, true); } } public function testFindByPropertiesLogsWhenConfigDropsFields(): void { // dropped fields are never stored, so every narrowed match gets a warning AmpConfig::set('album_grouping_fields', 'name,year,prefix,mbid,mbid_group,album_artist,release_type,release_status,original_year,catalog_number,version', true); try { $this->connection->method('fetchOne')->willReturn('666'); $this->logger->expects(static::once()) ->method('warning') ->with( 'album 666: matched with `album_grouping_fields` narrowed (dropped: barcode) - not recommended, dropped fields are never stored on the album', [LegacyLogger::CONTEXT_TYPE => AlbumRepository::class] ); self::assertSame(666, $this->subject->findByProperties($this->createProperties())); } finally { AmpConfig::set('album_grouping_fields', null, true); } } public function testFindByPropertiesMatchesUnsetPropertiesAgainstNull(): void { // an unset property has to be matched as NULL, or a partially tagged release collides with a fully tagged one $this->connection->expects(static::once()) ->method('fetchOne') ->with( "SELECT DISTINCT(`album`.`id`) AS `id` FROM `album` WHERE (`album`.`name` = ? OR LTRIM(CONCAT(COALESCE(`album`.`prefix`, ''), ' ', `album`.`name`)) = ?) AND `album`.`year` = ? AND `album`.`prefix` = ? AND `album`.`mbid` IS NULL AND `album`.`mbid_group` IS NULL AND `album`.`album_artist` = ? AND `album`.`release_type` IS NULL AND `album`.`release_status` IS NULL AND `album`.`original_year` IS NULL AND `album`.`barcode` IS NULL AND `album`.`catalog_number` IS NULL AND `album`.`version` IS NULL AND `album`.`catalog` = ?;", ['some-album', 'some-album', 1999, 'The', 42, 7] ) ->willReturn('666'); self::assertSame(666, $this->subject->findByProperties($this->createProperties())); } public function testFindByPropertiesMbidOnlyMatchesAnyUntaggedAlbumInTheCatalog(): void { // with name/year dropped from the set entirely, an untagged release (both mbid fields null, as // createProperties() sets up) matches on `mbid IS NULL AND mbid_group IS NULL` alone - so every // other untagged album already in the catalog is a candidate match, not just same-named ones AmpConfig::set('album_grouping_fields', 'mbid,mbid_group', true); try { $this->connection->expects(static::once()) ->method('fetchOne') ->with( 'SELECT DISTINCT(`album`.`id`) AS `id` FROM `album` WHERE `album`.`mbid` IS NULL AND `album`.`mbid_group` IS NULL AND `album`.`catalog` = ?;', [7] ) ->willReturn('666'); self::assertSame(666, $this->subject->findByProperties($this->createProperties())); } finally { AmpConfig::set('album_grouping_fields', null, true); } } public function testFindByPropertiesNameAndYearOnlyDropsEveryTagField(): void { AmpConfig::set('album_grouping_fields', 'name,year', true); try { $this->connection->expects(static::once()) ->method('fetchOne') ->with( "SELECT DISTINCT(`album`.`id`) AS `id` FROM `album` WHERE (`album`.`name` = ? OR LTRIM(CONCAT(COALESCE(`album`.`prefix`, ''), ' ', `album`.`name`)) = ?) AND `album`.`year` = ? AND `album`.`catalog` = ?;", ['some-album', 'some-album', 1999, 7] ) ->willReturn('666'); self::assertSame(666, $this->subject->findByProperties($this->createProperties())); } finally { AmpConfig::set('album_grouping_fields', null, true); } } public function testFindByPropertiesNarrowsIdentityColumnsFromConfig(): void { // a column left out of `album_grouping_fields` is dropped entirely, not matched as NULL either, // so albums differing only there merge into one instead of staying split AmpConfig::set('album_grouping_fields', 'name,year,album_artist,mbid', true); try { $this->connection->expects(static::once()) ->method('fetchOne') ->with( "SELECT DISTINCT(`album`.`id`) AS `id` FROM `album` WHERE (`album`.`name` = ? OR LTRIM(CONCAT(COALESCE(`album`.`prefix`, ''), ' ', `album`.`name`)) = ?) AND `album`.`year` = ? AND `album`.`mbid` IS NULL AND `album`.`album_artist` = ? AND `album`.`catalog` = ?;", ['some-album', 'some-album', 1999, 42, 7] ) ->willReturn('666'); self::assertSame(666, $this->subject->findByProperties($this->createProperties())); } finally { AmpConfig::set('album_grouping_fields', null, true); } } public function testFindByPropertiesReturnsNullWhenNothingMatched(): void { $this->connection->method('fetchOne')->willReturn(false); self::assertNull($this->subject->findByProperties($this->createProperties())); } public function testFindEmptyKeepsANullAlbumArtistNull(): void { $result = $this->createMock(PDOStatement::class); $this->connection->expects(static::once()) ->method('query') ->with('SELECT `id`, `album_artist` FROM `album` WHERE NOT EXISTS (SELECT `id` FROM `song` WHERE `song`.`album` = `album`.`id`);') ->willReturn($result); $result->expects(static::exactly(3)) ->method('fetch') ->willReturn( ['id' => '666', 'album_artist' => '42'], ['id' => '667', 'album_artist' => null], false ); self::assertSame( [ ['id' => 666, 'album_artist' => 42], ['id' => 667, 'album_artist' => null], ], $this->subject->findEmpty() ); } public function testForgettingOneAlbumLeavesTheOthersCached(): void { Album::add_to_cache('album_artists', 666, [1]); Album::add_to_cache('album_artists', 667, [2]); $this->connection->expects(static::once()) ->method('query'); $this->subject->addAlbumMap(666, 'album', 42); self::assertFalse(Album::is_cached('album_artists', 666)); self::assertTrue(Album::is_cached('album_artists', 667)); } public function testGetAlbumArtistIdReturnsAlbumArtistId(): void { $albumId = 666; $result = 42; $this->connection->expects(static::once()) ->method('fetchOne') ->with( 'SELECT DISTINCT `album_artist` FROM `album` WHERE `id` = ?;', [$albumId] ) ->willReturn((string) $result); self::assertSame( $result, $this->subject->getAlbumArtistId($albumId) ); } public function testGetAlbumArtistIdReturnsNullIfNotFound(): void { $albumId = 666; $this->connection->expects(static::once()) ->method('fetchOne') ->with( 'SELECT DISTINCT `album_artist` FROM `album` WHERE `id` = ?;', [$albumId] ) ->willReturn(false); self::assertNull( $this->subject->getAlbumArtistId($albumId) ); } /** * The api and upnp listings of an artist's albums do not go through a browse either, so the withdrawal * has to be read here or a release taken off the shelves is handed out by every device protocol. */ public function testGetAlbumByArtistHidesAWithdrawnAlbumFromALowerLevel(): void { $this->bootPrivilegeChecker(false); $result = $this->createMock(PDOStatement::class); $this->connection->expects(static::once()) ->method('query') ->with(self::stringContains('AND `album`.`enabled` = 1')) ->willReturn($result); $result->method('fetch')->willReturn(false); $this->subject->getAlbumByArtist(42); } /** * Deleting an artist takes its albums with it, and retagging one carries down to them, so those two read * the whole list. A filtered one leaves a withdrawn album behind with nothing above it. */ public function testGetAlbumByArtistKeepsTheWithdrawnOnesForMaintenance(): void { $this->bootPrivilegeChecker(false); $result = $this->createMock(PDOStatement::class); $this->connection->expects(static::once()) ->method('query') ->with(self::logicalNot(self::stringContains('`album`.`enabled`'))) ->willReturn($result); $result->method('fetch')->willReturn(false); $this->subject->getAlbumByArtist(42, false); } public function testGetArtistMapReturnsArtistList(): void { $album = $this->createMock(Album::class); $result = $this->createMock(PDOStatement::class); $objectType = 'some-object'; $albumId = 666; $artistId = 42; $album->expects(static::once()) ->method('getId') ->willReturn($albumId); $this->connection->expects(static::once()) ->method('query') ->with( 'SELECT `object_id` FROM `album_map` WHERE `object_type` = ? AND `album_id` = ?', [$objectType, $albumId] ) ->willReturn($result); $result->expects(static::exactly(2)) ->method('fetchColumn') ->willReturn((string) $artistId, false); self::assertSame( [$artistId], $this->subject->getArtistMap($album, $objectType) ); } /** * An album taken off the shelves was still listed on the page of its artist, for everybody, because this * listing builds its own sql instead of going through Query::_get_filter_sql() */ public function testGetByArtistHidesAWithdrawnAlbumFromALowerLevel(): void { $this->bootPrivilegeChecker(false); $result = $this->createMock(PDOStatement::class); $this->connection->expects(static::once()) ->method('query') ->with(self::stringContains('AND `album`.`enabled` = 1')) ->willReturn($result); $result->method('fetch')->willReturn(false); $this->subject->getByArtist(42); } public function testGetByArtistLeavesTheConditionOutForAManager(): void { $this->bootPrivilegeChecker(true); $result = $this->createMock(PDOStatement::class); $this->connection->expects(static::once()) ->method('query') ->with(self::logicalNot(self::stringContains('`album`.`enabled`'))) ->willReturn($result); $result->method('fetch')->willReturn(false); $this->subject->getByArtist(42); } public function testGetByMbidGroupReturnsData(): void { $musicBrainzId = '1234'; $albumId = 666; $result = $this->createMock(PDOStatement::class); $this->connection->expects(static::once()) ->method('query') ->with( 'SELECT `album`.`id` FROM `album` WHERE `album`.`mbid_group` = ?', [$musicBrainzId] ) ->willReturn($result); $result->expects(static::exactly(2)) ->method('fetchColumn') ->willReturn((string) $albumId, false); self::assertSame( [$albumId], $this->subject->getByMbidGroup($musicBrainzId) ); } public function testGetByName(): void { $albumId = 666; $name = 'some-name'; $artistId = 1234; $result = $this->createMock(PDOStatement::class); $this->connection->expects(static::once()) ->method('query') ->with( "SELECT `album`.`id` FROM `album` WHERE (`album`.`name` = ? OR LTRIM(CONCAT(COALESCE(`album`.`prefix`, ''), ' ', `album`.`name`)) = ?) AND `album`.`album_artist` = ?", [$name, $name, $artistId] ) ->willReturn($result); $result->expects(static::exactly(2)) ->method('fetchColumn') ->willReturn((string) $albumId, false); self::assertSame( [$albumId], $this->subject->getByName($name, $artistId) ); } public function testGetIdentityColumnsDeduplicatesRepeatedFields(): void { AmpConfig::set('album_grouping_fields', 'name,name,year,year,year', true); try { self::assertSame(['name', 'year'], $this->subject->getIdentityColumns()); } finally { AmpConfig::set('album_grouping_fields', null, true); } } public function testGetIdentityColumnsDropsEmptyEntriesFromATrailingComma(): void { AmpConfig::set('album_grouping_fields', 'name,year,', true); try { self::assertSame(['name', 'year'], $this->subject->getIdentityColumns()); } finally { AmpConfig::set('album_grouping_fields', null, true); } } public function testGetIdentityColumnsFallsBackToDefaultsWhenEveryEntryIsUnknown(): void { // a config string that matches no known column is as good as unset - it falls back to the full // default list rather than collapsing to just `catalog`, same as an empty/unset config does AmpConfig::set('album_grouping_fields', 'totally_invalid, also_bogus', true); try { self::assertSame( ['name', 'year', 'prefix', 'mbid', 'mbid_group', 'album_artist', 'release_type', 'release_status', 'original_year', 'barcode', 'catalog_number', 'version'], $this->subject->getIdentityColumns() ); $this->connection->expects(static::once()) ->method('fetchOne') ->with( "SELECT DISTINCT(`album`.`id`) AS `id` FROM `album` WHERE (`album`.`name` = ? OR LTRIM(CONCAT(COALESCE(`album`.`prefix`, ''), ' ', `album`.`name`)) = ?) AND `album`.`year` = ? AND `album`.`prefix` = ? AND `album`.`mbid` IS NULL AND `album`.`mbid_group` IS NULL AND `album`.`album_artist` = ? AND `album`.`release_type` IS NULL AND `album`.`release_status` IS NULL AND `album`.`original_year` IS NULL AND `album`.`barcode` IS NULL AND `album`.`catalog_number` IS NULL AND `album`.`version` IS NULL AND `album`.`catalog` = ?;", ['some-album', 'some-album', 1999, 'The', 42, 7] ) ->willReturn('666'); self::assertSame(666, $this->subject->findByProperties($this->createProperties())); } finally { AmpConfig::set('album_grouping_fields', null, true); } } public function testGetIdentityColumnsIgnoresTheOrderOfTheConfiguredList(): void { // array_intersect() keeps IDENTITY_COLUMNS's own order regardless of how the admin ordered the CSV - // the config string is read as a set, never as a sequence AmpConfig::set('album_grouping_fields', 'version,prefix,name,catalog_number,year', true); try { self::assertSame( ['name', 'year', 'prefix', 'catalog_number', 'version'], $this->subject->getIdentityColumns() ); } finally { AmpConfig::set('album_grouping_fields', null, true); } } public function testGetIdentityColumnsTrimsWhitespaceAroundEveryField(): void { AmpConfig::set('album_grouping_fields', " name , year , album_artist ", true); try { self::assertSame(['name', 'year', 'album_artist'], $this->subject->getIdentityColumns()); } finally { AmpConfig::set('album_grouping_fields', null, true); } } public function testGetIdsByCatalogReadsTheAlbumsWithNoArt(): void { $result = $this->createMock(PDOStatement::class); $this->connection->expects(static::once()) ->method('query') ->with( "SELECT `album`.`id` FROM `album` LEFT JOIN `image` ON `album`.`id` = `image`.`object_id` AND `object_type` = 'album' AND `image`.`size` = 'original' WHERE `album`.`catalog` = ? AND `image`.`object_id` IS NULL", [7] ) ->willReturn($result); $result->expects(static::exactly(2)) ->method('fetchColumn') ->willReturn('666', false); self::assertSame([666], $this->subject->getIdsByCatalog(7, true)); } public function testGetIdsByCatalogsCastsEveryCatalogIdAndPages(): void { $result = $this->createMock(PDOStatement::class); $this->connection->expects(static::once()) ->method('query') ->with('SELECT `album`.`id` FROM `song` INNER JOIN `album` ON `album`.`id` = `song`.`album` WHERE `song`.`catalog` IN (1,0) AND `album`.`enabled` = 1 GROUP BY `album`.`id` ORDER BY `album`.`name` LIMIT 20, 10') ->willReturn($result); $result->expects(static::once()) ->method('fetchColumn') ->willReturn(false); self::assertSame([], $this->subject->getIdsByCatalogs([1, 'x9'], 10, 20)); } public function testGetIdsByCatalogsOrderedByArtistGroupsBySongAlbumForACatalogList(): void { $result = $this->createMock(PDOStatement::class); $this->connection->expects(static::once()) ->method('query') ->with('SELECT `song`.`album` AS `id` FROM `song` INNER JOIN `album` ON `album`.`id` = `song`.`album` LEFT JOIN `artist` ON `artist`.`id` = `album`.`album_artist` WHERE `song`.`catalog` IN (3) AND `album`.`enabled` = 1 GROUP BY `song`.`album`, `artist`.`name`, `artist`.`id`, `album`.`name`, `album`.`mbid` ORDER BY `artist`.`name`, `artist`.`id`, `album`.`name` ') ->willReturn($result); $result->expects(static::once()) ->method('fetchColumn') ->willReturn(false); self::assertSame([], $this->subject->getIdsByCatalogsOrderedByArtist([3])); } public function testGetIdsByCatalogsRunsToTheEndForAnOffsetWithNoSize(): void { $result = $this->createMock(PDOStatement::class); $this->connection->expects(static::once()) ->method('query') ->with('SELECT `album`.`id` FROM `album` WHERE `album`.`enabled` = 1 GROUP BY `album`.`id` ORDER BY `album`.`name` LIMIT 5, 18446744073709551615') ->willReturn($result); $result->expects(static::once()) ->method('fetchColumn') ->willReturn(false); self::assertSame([], $this->subject->getIdsByCatalogs(null, 0, 5)); } public function testGetMappedObjectIdsBulkDoesNothingForNoAlbums(): void { $this->connection->expects(static::never()) ->method('query'); self::assertSame([], $this->subject->getMappedObjectIdsBulk([], 'song')); } public function testGetMappedObjectIdsBulkKeysTheRowsByAlbumAndKeepsTheEmptyOnes(): void { $result = $this->createMock(PDOStatement::class); $this->connection->expects(static::once()) ->method('query') ->with( 'SELECT `album_id`, `object_id` FROM `album_map` WHERE `object_type` = ? AND `album_id` IN (?,?)', ['song', 1, 2] ) ->willReturn($result); $result->method('fetch')->willReturnOnConsecutiveCalls( ['album_id' => '2', 'object_id' => '10'], ['album_id' => '2', 'object_id' => '11'], false ); self::assertSame( [1 => [], 2 => [10, 11]], $this->subject->getMappedObjectIdsBulk([1, 2], 'song') ); } public function testGetNamesReadsTheCachedRowInsteadOfTheDatabase(): void { Album::add_to_cache('album', 666, ['id' => 666, 'prefix' => null, 'name' => 'Some Album']); $this->connection->expects(static::never()) ->method('fetchRow'); self::assertSame( [ 'prefix' => null, 'basename' => 'Some Album', 'name' => 'Some Album', ], $this->subject->getNames(666) ); Album::clear_cache(); } public function testGetNamesReturnsArrayWithDefaultsIfEmpty(): void { $albumId = 666; $this->connection->expects(static::once()) ->method('fetchRow') ->with( "SELECT `album`.`prefix`, `album`.`name` AS `basename`, LTRIM(CONCAT(COALESCE(`album`.`prefix`, ''), ' ', `album`.`name`)) AS `name` FROM `album` WHERE `id` = ?", [$albumId] ) ->willReturn(false); self::assertSame( [ 'prefix' => '', 'basename' => '', 'name' => '' ], $this->subject->getNames($albumId) ); } public function testGetNamesReturnsRow(): void { $albumId = 666; $data = ['some-data']; $this->connection->expects(static::once()) ->method('fetchRow') ->with( "SELECT `album`.`prefix`, `album`.`name` AS `basename`, LTRIM(CONCAT(COALESCE(`album`.`prefix`, ''), ' ', `album`.`name`)) AS `name` FROM `album` WHERE `id` = ?", [$albumId] ) ->willReturn($data); self::assertSame( $data, $this->subject->getNames($albumId) ); } public function testGetRandomBuildsTheCatalogFilterAndDelegatesToTheSampler(): void { $this->bootCatalogRepository([5, 7]); $this->randomIdSampler->expects(static::once()) ->method('sample') ->with('album', 'id', 'WHERE `album`.`catalog` IN (5,7,0) AND `album`.`enabled` = 1', [], 3) ->willReturn([10, 11, 12]); self::assertSame([10, 11, 12], $this->subject->getRandom(42, 3)); } public function testGetRandomNarrowsToTheRequestedCatalogWhenTheUserCanSeeIt(): void { $this->bootCatalogRepository([5, 7]); $this->randomIdSampler->expects(static::once()) ->method('sample') ->with('album', 'id', 'WHERE `album`.`catalog` IN (5) AND `album`.`enabled` = 1', [], 1) ->willReturn([10]); self::assertSame([10], $this->subject->getRandom(42, 1, 5)); } public function testGetRandomReturnsNothingWhenTheRequestedCatalogIsNotVisible(): void { $this->bootCatalogRepository([5, 7]); $this->randomIdSampler->expects(static::never())->method('sample'); self::assertSame([], $this->subject->getRandom(42, 3, 99)); } public function testGetRandomSongsReturnsIds(): void { $this->bootCatalogRepository([]); $result = $this->createMock(PDOStatement::class); $this->connection->expects(static::once()) ->method('query') ->with( 'SELECT `song`.`id` FROM `song` WHERE `song`.`album` = ? AND `song`.`enabled` = 1 ORDER BY RAND()', [666] ) ->willReturn($result); $result->expects(static::exactly(3)) ->method('fetchColumn') ->willReturn('42', '33', false); self::assertSame( [42, 33], $this->subject->getRandomSongs(666) ); } public function testGetSongsByAlbumDiskReturnsIdsInDiskTrackOrder(): void { $result = $this->createMock(PDOStatement::class); $this->connection->expects(static::once()) ->method('query') ->with( 'SELECT `song`.`id` FROM `song` LEFT JOIN `album_disk` ON `album_disk`.`album_id` = `song`.`album` AND `album_disk`.`disk` = `song`.`disk` WHERE `album_disk`.`id` = ? ORDER BY `song`.`disk`, `song`.`track`, `song`.`title`', [666] ) ->willReturn($result); $result->expects(static::exactly(2)) ->method('fetchColumn') ->willReturn('42', false); self::assertSame( [42], $this->subject->getSongsByAlbumDisk(666) ); } public function testIsOrphanMatchesTheUntranslatedPlaceholderToo(): void { $this->connection->expects(static::once()) ->method('fetchOne') ->with( "SELECT `id` FROM `album` WHERE `id` = ? AND (`name` = 'Unknown (Orphaned)' OR `name` = ?);", [666, T_('Unknown (Orphaned)')] ) ->willReturn('666'); self::assertTrue($this->subject->isOrphan(666)); } public function testIsOrphanReturnsFalseWhenNothingMatched(): void { $this->connection->method('fetchOne')->willReturn(false); self::assertFalse($this->subject->isOrphan(666)); } public function testRemoveAlbumMapDeletes(): void { $this->connection->expects(static::once()) ->method('query') ->with( 'DELETE FROM `album_map` WHERE `album_id` = ? AND `object_type` = ? AND `object_id` = ?;', [666, 'song', 42] ); $this->subject->removeAlbumMap(666, 'song', 42); } public function testRemoveAlbumMapForgetsTheCachedArtistList(): void { Album::add_to_cache('album_artists', 666, [1, 2, 3]); $this->connection->expects(static::once()) ->method('query'); $this->subject->removeAlbumMap(666, 'album', 42); self::assertFalse(Album::is_cached('album_artists', 666)); } public function testRemoveUnusedAlbumMapKeepsTheRowWhileTheArtistMapBacksIt(): void { $this->connection->expects(static::once()) ->method('fetchOne') ->with( 'SELECT `artist_id` FROM `artist_map` WHERE `artist_id` = ? AND `object_id` = ? AND `object_type` = ?;', [42, 666, 'album'] ) ->willReturn('42'); $this->connection->expects(static::never())->method('query'); self::assertFalse($this->subject->removeUnusedAlbumMap(666, 'album', 42)); } public function testRemoveUnusedAlbumMapLooksThroughTheSongsForATrackArtist(): void { // a `song` mapping survives while any track on the album still credits the artist, hence the subquery $this->connection->expects(static::once()) ->method('fetchOne') ->with( 'SELECT `artist_id` FROM `artist_map` WHERE `artist_id` = ? AND `object_id` IN (SELECT `id` FROM `song` WHERE `album` = ?) AND `object_type` = ?;', [42, 666, 'song'] ) ->willReturn(false); $this->connection->expects(static::once()) ->method('query') ->with( 'DELETE FROM `album_map` WHERE `album_id` = ? AND `object_type` = ? AND `object_id` = ?;', [666, 'song', 42] ); self::assertTrue($this->subject->removeUnusedAlbumMap(666, 'song', 42)); } public function testSetFieldReturnsFalseWhenTheWriteFailed(): void { $this->connection->expects(static::once()) ->method('query') ->willThrowException(new QueryFailedException('some-error')); self::assertFalse($this->subject->setField(666, AlbumFieldEnum::NAME, 'some-name')); } public function testSetFieldWritesNullWithoutASpecialStatement(): void { $this->connection->expects(static::once()) ->method('query') ->with('UPDATE `album` SET `original_year` = ? WHERE `id` = ?', [null, 666]); self::assertTrue($this->subject->setField(666, AlbumFieldEnum::ORIGINAL_YEAR, null)); } public function testSetFieldWritesTheColumnFromTheEnum(): void { $this->connection->expects(static::once()) ->method('query') ->with('UPDATE `album` SET `catalog_number` = ? WHERE `id` = ?', ['some-number', 666]); self::assertTrue($this->subject->setField(666, AlbumFieldEnum::CATALOG_NUMBER, 'some-number')); } public function testSetSongsEnabledCarriesTheAlbumStateDownToEverySong(): void { $bound = []; $counts = 0; $this->connection->expects(static::exactly(4)) ->method('query') ->willReturnCallback(function (string $sql, array $params) use (&$bound, &$counts): PDOStatement { if (str_contains($sql, '`song_count`')) { // the stored count is brought back in the same breath, or the album keeps announcing // tracks nobody can play until the next maintenance sweep self::assertSame([666], $params); $counts++; } else { self::assertStringContainsString('UPDATE `song` SET `enabled` = ? WHERE `album` = ?', $sql); $bound[] = $params; } return $this->createMock(PDOStatement::class); }); $this->subject->setSongsEnabled(666, false); $this->subject->setSongsEnabled(666, true); // the tell that the cascade runs both ways: the same statement carries 0 and then 1 self::assertSame([[0, 666], [1, 666]], $bound); self::assertSame(2, $counts); } public function testUpdateAllCountsRunsTheWholeSweepEvenWhenOneStatementFails(): void { // a maintenance statement that dies must not take the rest of the sweep with it, as `Dba::write()` did not $this->connection->expects(static::exactly(16)) ->method('query') ->willThrowException(new QueryFailedException('some-error')); $this->subject->updateAllCounts(); } public function testUpdateAllSkipCountsRollsUpBothAlbumAndAlbumDisk(): void { $calls = []; $this->connection->expects(static::exactly(2)) ->method('query') ->willReturnCallback(function (string $sql) use (&$calls): PDOStatement { $calls[] = $sql; return $this->createMock(PDOStatement::class); }); $this->subject->updateAllSkipCounts(); self::assertStringStartsWith('UPDATE `album`,', $calls[0]); self::assertStringStartsWith('UPDATE `album_disk`,', $calls[1]); } public function testUpdateAllSkipCountsSumsTheWholeAlbumButGroupsTheDisk(): void { $calls = []; $this->connection->expects(static::exactly(2)) ->method('query') ->willReturnCallback(function (string $sql) use (&$calls): PDOStatement { $calls[] = $sql; return $this->createMock(PDOStatement::class); }); $this->subject->updateAllSkipCounts(); // grouping the album rollup by disk would give a multi-disk album one disk's total self::assertStringContainsString('GROUP BY `song`.`album`)', $calls[0]); self::assertStringContainsString('GROUP BY `song`.`album`, `song`.`disk`)', $calls[1]); } public function testUpdateCountsBindsTheAlbumIntoEveryStatement(): void { $bound = []; $this->connection->expects(static::exactly(15)) ->method('query') ->willReturnCallback(function (string $sql, array $params) use (&$bound): PDOStatement { $bound[] = $params; return $this->createMock(PDOStatement::class); }); $this->subject->updateCounts(666); foreach ($bound as $params) { self::assertSame([666], array_unique($params)); } } protected function setUp(): void { $this->connection = $this->createMock(DatabaseConnectionInterface::class); $this->logger = $this->createMock(LoggerInterface::class); $this->randomIdSampler = $this->createMock(RandomIdSamplerInterface::class); $this->subject = new AlbumRepository( $this->connection, $this->logger, $this->randomIdSampler, ); // the object cache is a process-wide static, so a leftover entry would leak between tests Album::clear_cache(); } /** * @param list $catalogIds */ private function bootCatalogRepository(array $catalogIds): void { $catalogRepository = $this->createMock(CatalogRepositoryInterface::class); $catalogRepository->method('getIds')->willReturn($catalogIds); // a non-manager, so the withdrawn-item condition WithdrawnFilter adds stays in the expected SQL $privilegeChecker = $this->createMock(PrivilegeCheckerInterface::class); $privilegeChecker->method('check')->willReturn(false); $dic = $this->createMock(ContainerInterface::class); $dic->method('get')->willReturnCallback( fn(string $id): object => ($id === PrivilegeCheckerInterface::class) ? $privilegeChecker : $catalogRepository ); $GLOBALS['dic'] = $dic; } private function bootPrivilegeChecker(bool $isManager): void { $privilegeChecker = $this->createMock(PrivilegeCheckerInterface::class); $privilegeChecker->method('check')->willReturn($isManager); // `Catalog::get_catalogs()` reaches the same container on the way through, so it cannot all be the checker $catalogRepository = $this->createMock(CatalogRepositoryInterface::class); $catalogRepository->method('getIds')->willReturn([]); $dic = $this->createMock(ContainerInterface::class); $dic->method('get')->willReturnCallback( fn(string $id): object => ($id === PrivilegeCheckerInterface::class) ? $privilegeChecker : $catalogRepository ); $GLOBALS['dic'] = $dic; } /** * @return array{name: string, prefix: ?string, year: int, mbid: ?string, mbid_group: ?string, release_type: ?string, release_status: ?string, album_artist: ?int, original_year: ?string, barcode: ?string, catalog_number: ?string, version: ?string, catalog: int} */ private function createProperties(): array { return [ 'name' => 'some-album', 'prefix' => 'The', 'year' => 1999, 'mbid' => null, 'mbid_group' => null, 'release_type' => null, 'release_status' => null, 'album_artist' => 42, 'original_year' => null, 'barcode' => null, 'catalog_number' => null, 'version' => null, 'catalog' => 7, ]; } }