Ctrl K
ComunidadGuías…

tests/Repository/AlbumRepositoryTest.php

Rama develop · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
<?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,
        ];
    }
}