tests/Repository/AlbumRepositoryTest.php
Rama develop · réplica de lectura del repositorio original.
<?php
declare(strict_types=1);
/**
* vim:set softtabstop=4 shiftwidth=4 expandtab:
*
* LICENSE: GNU Affero General Public License, version 3 (AGPL-3.0-or-later)
* Copyright Ampache.org, 2001-2026
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
*/
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<int> $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,
];
}
}