Ctrl K
ComunidadGuías…

tests/Repository/ArtistRepositoryTest.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\Module\Authorization\Check\PrivilegeCheckerInterface;
use Ampache\Module\Database\DatabaseConnectionInterface;
use Ampache\Module\Database\Exception\QueryFailedException;
use Ampache\Module\Database\RandomIdSamplerInterface;
use Ampache\Repository\Model\Artist;
use Ampache\Repository\Model\ArtistFieldEnum;
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 ArtistRepositoryTest extends TestCase
{
    use ConsecutiveParams;

    private DatabaseConnectionInterface&MockObject $connection;
    private LoggerInterface&MockObject $logger;
    private RandomIdSamplerInterface&MockObject $randomIdSampler;
    private ArtistRepository $subject;

    public function testAddArtistMapInsertsIgnoringDuplicates(): void
    {
        $this->connection->expects(static::once())
            ->method('query')
            ->with(
                'INSERT IGNORE INTO `artist_map` (`artist_id`, `object_type`, `object_id`) VALUES (?, ?, ?);',
                [666, 'song', 42]
            );

        $this->subject->addArtistMap(666, 'song', 42);
    }

    public function testCollectGarbageCleansUp(): void
    {
        $this->connection->expects(static::exactly(5))
            ->method('query')
            ->with(
                ...self::withConsecutive(
                    ['DELETE FROM `artist_map` WHERE `artist_map`.`object_type` = ? AND `artist_map`.`object_id` IN (SELECT `id` FROM `album` WHERE `album_artist` IS NULL);'],
                    ['DELETE FROM `artist_map` WHERE `artist_map`.`object_type` = ? AND `artist_map`.`object_id` NOT IN (SELECT `id` FROM `album`);'],
                    ['DELETE FROM `artist_map` WHERE `artist_map`.`object_type` = ? AND `artist_map`.`object_id` NOT IN (SELECT `id` FROM `song`);'],
                    ['DELETE FROM `artist_map` WHERE `artist_map`.`artist_id` NOT IN (SELECT `id` FROM `artist`);'],
                    ['DELETE FROM `artist` WHERE `id` IN (SELECT `id` FROM (SELECT `id` FROM `artist` LEFT JOIN (SELECT DISTINCT `song`.`artist` AS `artist_id` FROM `song` UNION SELECT DISTINCT `album`.`album_artist` AS `artist_id` FROM `album` UNION SELECT DISTINCT `wanted`.`artist` AS `artist_id` FROM `wanted` UNION SELECT DISTINCT `artist_id` FROM `artist_map`) AS `artist_map` ON `artist_map`.`artist_id` = `artist`.`id` WHERE `artist_map`.`artist_id` IS NULL AND `artist`.`user` IS NULL) AS `null_artist`);'],
                )
            );

        $this->subject->collectGarbage();
    }

    public function testCollectOrphanedMapsSweepsBothHalvesAndCarriesOnAfterAFailure(): void
    {
        $calls = [];

        $this->connection->expects(static::exactly(2))
            ->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->subject->collectOrphanedMaps();

        self::assertStringContainsString("`artist_map`.`object_type` = 'album'", $calls[0]);
        self::assertStringContainsString("`artist_map`.`object_type` = 'song'", $calls[1]);
    }

    public function testCreateReturnsNullWhenTheInsertFailed(): void
    {
        // the caller reads null as "no artist" and gives up, so the exception must not escape
        $this->connection->expects(static::once())
            ->method('query')
            ->willThrowException(new QueryFailedException('some-error'));

        self::assertNull($this->subject->create('some-artist', null, null, null));
    }

    public function testCreateReturnsTheNewId(): void
    {
        $this->connection->expects(static::once())
            ->method('query')
            ->with(
                'INSERT INTO `artist` (`name`, `prefix`, `mbid`, `user`) VALUES(?, ?, ?, ?)',
                ['some-artist', 'The', 'some-mbid', 42]
            );

        $this->connection->expects(static::once())
            ->method('getLastInsertedId')
            ->willReturn(666);

        self::assertSame(666, $this->subject->create('some-artist', 'The', 'some-mbid', 42));
    }

    public function testDeleteDeletes(): void
    {
        $artistId = 666;

        $artist = $this->createMock(Artist::class);

        $artist->expects(static::once())
            ->method('getId')
            ->willReturn($artistId);

        $this->connection->expects(static::once())
            ->method('query')
            ->with(
                'DELETE FROM `artist` WHERE `id` = ?',
                [$artistId]
            );

        $this->subject->delete($artist);
    }

    public function testFindByNameReturnsNullIfNoEntryWasFound(): void
    {
        $value = 'snafu';

        $this->connection->expects(static::once())
            ->method('fetchOne')
            ->with(
                "SELECT `id` FROM `artist` WHERE `name` = ? OR LTRIM(CONCAT(COALESCE(`artist`.`prefix`, ''), ' ', `artist`.`name`)) = ? ",
                [$value, $value]
            )
            ->willReturn(false);

        self::assertNull(
            $this->subject->findByName($value)
        );
    }

    public function testFindDuplicateMbidGroupsReturnsTheLowestAndHighestIdPerMbid(): void
    {
        $result = $this->createMock(PDOStatement::class);

        $this->connection->expects(static::once())
            ->method('query')
            ->with('SELECT `mbid`, MIN(`id`) AS `minid`, MAX(`id`) AS `maxid` FROM `artist` WHERE `mbid` IS NOT NULL GROUP BY `mbid` HAVING COUNT(`mbid`) > 1;')
            ->willReturn($result);

        $result->expects(static::exactly(2))
            ->method('fetch')
            ->willReturn(['mbid' => 'some-mbid', 'minid' => '4', 'maxid' => '9'], false);

        self::assertSame(
            [['mbid' => 'some-mbid', 'minid' => 4, 'maxid' => 9]],
            $this->subject->findDuplicateMbidGroups()
        );
    }

    public function testFindIdByNamePicksTheStatementFromTheMbidFlag(): void
    {
        // the two statements differ only in the mbid predicate, and the caller tries them in that order
        $this->connection->expects(static::once())
            ->method('fetchOne')
            ->with(
                "SELECT `id` FROM `artist` WHERE `mbid` IS NOT NULL AND (`artist`.`name` = ? OR LTRIM(CONCAT(COALESCE(`artist`.`prefix`, ''), ' ', `artist`.`name`)) = ?) ORDER BY `id` LIMIT 1;",
                ['some-artist', 'some-artist feat. someone']
            )
            ->willReturn('666');

        self::assertSame(666, $this->subject->findIdByName('some-artist', 'some-artist feat. someone', true));
    }

    public function testGetArrayRowsByCatalogsBindsOneCatalogIntoTheSingleCatalogShape(): void
    {
        $result = $this->createMock(PDOStatement::class);

        $this->connection->expects(static::once())
            ->method('query')
            ->with(self::stringContains("`catalog_map`.`catalog_id` = 7 LEFT JOIN `image`"))
            ->willReturn($result);

        $result->expects(static::exactly(2))
            ->method('fetch')
            ->willReturn(
                [
                    'id' => '666',
                    'f_name' => 'The Band',
                    'name' => 'Band',
                    'album_count' => '2',
                    'song_count' => '20',
                    'catalog_id' => '7',
                    'has_art' => '666',
                ],
                false
            );

        self::assertSame(
            [[
                'id' => 666,
                'f_name' => 'The Band',
                'name' => 'Band',
                'album_count' => 2,
                'song_count' => 20,
                'catalog_id' => 7,
                'has_art' => 666,
            ]],
            $this->subject->getArrayRowsByCatalogs(['7'])
        );
    }

    public function testGetArrayRowsByCatalogsCastsEveryCatalogOfAList(): void
    {
        $result = $this->createMock(PDOStatement::class);

        $this->connection->expects(static::once())
            ->method('query')
            ->with(self::stringContains('`catalog_map`.`catalog_id` IN (1,0) LEFT JOIN `image`'))
            ->willReturn($result);

        $result->expects(static::once())
            ->method('fetch')
            ->willReturn(false);

        self::assertSame([], $this->subject->getArrayRowsByCatalogs([1, 'x9']));
    }

    public function testGetIdsByCatalogAddedSinceBindsBothTheCatalogAndTheTime(): void
    {
        $result = $this->createMock(PDOStatement::class);

        $this->connection->expects(static::once())
            ->method('query')
            ->with(
                'SELECT DISTINCT(`artist`.`id`) AS `artist` FROM `artist` WHERE `artist`.`id` IN (SELECT DISTINCT `song`.`artist` FROM `song` WHERE `song`.`catalog` = ? AND `addition_time` > ?) OR (`album_count` = 0 AND `song_count` = 0) ',
                [7, 123456]
            )
            ->willReturn($result);

        $result->expects(static::exactly(2))
            ->method('fetchColumn')
            ->willReturn('42', false);

        self::assertSame([42], $this->subject->getIdsByCatalogAddedSince(7, 123456));
    }

    public function testGetIdsMissingRecommendationTakesNoCatalogParameter(): void
    {
        $result = $this->createMock(PDOStatement::class);

        $this->connection->expects(static::once())
            ->method('query')
            ->with(
                "SELECT DISTINCT(`artist`.`id`) AS `artist` FROM `artist` WHERE `artist`.`id` NOT IN (SELECT `object_id` FROM `recommendation` WHERE `object_type` = 'artist') ORDER BY RAND() LIMIT 500;",
                []
            )
            ->willReturn($result);

        $result->expects(static::once())
            ->method('fetchColumn')
            ->willReturn(false);

        self::assertSame([], $this->subject->getIdsMissingRecommendation(500));
    }

    public function testGetRandomBuildsTheCatalogFilterAndDelegatesToTheSampler(): void
    {
        $this->bootCatalogRepository([5, 7]);

        $this->randomIdSampler->expects(static::once())
            ->method('sample')
            ->with(
                'artist',
                'id',
                'WHERE EXISTS (SELECT 1 FROM `artist_map` INNER JOIN `song` ON `song`.`artist` = `artist_map`.`artist_id` WHERE `artist_map`.`artist_id` = `artist`.`id` AND `song`.`catalog` IN (5,7,0)) AND `artist`.`enabled` = 1',
                [],
                3
            )
            ->willReturn([10, 11, 12]);

        self::assertSame([10, 11, 12], $this->subject->getRandom(42, 3));
    }

    public function testGetRowsByCatalogsFiltersOnTheSongCatalogWhenGivenAList(): void
    {
        $result = $this->createMock(PDOStatement::class);

        $this->connection->expects(static::once())
            ->method('query')
            ->with(self::stringContains('INNER JOIN `artist` ON `artist`.`id` = `song`.`artist` WHERE `artist`.`enabled` = 1 AND `song`.`catalog` IN (4) GROUP BY '))
            ->willReturn($result);

        $result->expects(static::once())
            ->method('fetch')
            ->willReturn(false);

        self::assertSame([], $this->subject->getRowsByCatalogs(['4']));
    }

    public function testGetUploaderIdReturnsZeroWhenTheArtistWasNotUploaded(): void
    {
        $this->connection->method('fetchOne')->willReturn(false);

        self::assertSame(0, $this->subject->getUploaderId(666));
    }

    public function testMigrateClearsTheCreditWhenThereIsNoReplacement(): void
    {
        $this->connection->expects(static::exactly(4))
            ->method('query')
            ->with(
                ...self::withConsecutive(
                    ['UPDATE `song` SET `artist` = NULL WHERE `artist` = ?;', [666]],
                    ['UPDATE `album` SET `album_artist` = NULL WHERE `album_artist` = ?;', [666]],
                    ['DELETE FROM `artist_map` WHERE `artist_id` = ?;', [666]],
                    ["DELETE FROM `album_map` WHERE `object_id` = ? AND `object_type` = 'album';", [666]],
                )
            );

        $this->subject->migrate(666, 0);
    }

    public function testMigrateMovesEverythingOntoTheNewArtist(): void
    {
        $this->connection->expects(static::exactly(6))
            ->method('query')
            ->with(
                ...self::withConsecutive(
                    ['UPDATE `song` SET `artist` = ? WHERE `artist` = ?;', [42, 666]],
                    ['UPDATE `album` SET `album_artist` = ? WHERE `album_artist` = ?;', [42, 666]],
                    ['UPDATE IGNORE `artist_map` SET `artist_id` = ? WHERE `artist_id` = ?;', [42, 666]],
                    ["UPDATE IGNORE `album_map` SET `object_id` = ? WHERE `object_id` = ? AND `object_type` = 'album';", [42, 666]],
                    ['DELETE FROM `artist_map` WHERE `artist_id` = ?;', [666]],
                    ["DELETE FROM `album_map` WHERE `object_id` = ? AND `object_type` = 'album';", [666]],
                )
            );

        $this->subject->migrate(666, 42);
    }

    public function testSetChildrenEnabledCarriesTheArtistStateToItsAlbumsAndSongs(): void
    {
        $statements = [];

        $this->connection->expects(static::exactly(3))
            ->method('query')
            ->willReturnCallback(function (string $sql, array $params) use (&$statements): PDOStatement {
                $statements[] = [$sql, $params];

                return $this->createMock(PDOStatement::class);
            });

        $this->subject->setChildrenEnabled(666, false);

        // the third pass repairs the stored counts of the albums the first two just changed
        self::assertStringContainsString('`song_count`', $statements[2][0]);
        self::assertSame([666], $statements[2][1]);

        self::assertStringContainsString('UPDATE `album`', $statements[0][0]);
        self::assertSame([0, 666], $statements[0][1]);
        // an album leaving the listings has to take playback with it, or the word means two things
        self::assertStringContainsString('UPDATE `song`', $statements[1][0]);
        self::assertSame([0, 666], $statements[1][1]);
    }

    public function testSetChildrenEnabledPutsBackWhatItTookAway(): void
    {
        $bound = [];

        $this->connection->expects(static::exactly(3))
            ->method('query')
            ->willReturnCallback(function (string $sql, array $params) use (&$bound): PDOStatement {
                $bound[] = $params;

                return $this->createMock(PDOStatement::class);
            });

        $this->subject->setChildrenEnabled(666, true);

        self::assertSame([[1, 666], [1, 666], [666]], $bound);
    }

    public function testSetFieldWritesTheColumnFromTheEnum(): void
    {
        $this->connection->expects(static::once())
            ->method('query')
            ->with('UPDATE `artist` SET `mbid` = ? WHERE `id` = ?', ['some-mbid', 666]);

        self::assertTrue($this->subject->setField(666, ArtistFieldEnum::MBID, 'some-mbid'));
    }

    /**
     * Binary: an item counts when it is enabled, and the state of its parent never enters into it.
     */
    public function testUpdateAllCountsLeavesOutWhatCannotBePlayedOrListed(): void
    {
        $statements = [];

        $this->connection->method('query')
            ->willReturnCallback(function (string $sql) use (&$statements): PDOStatement {
                $statements[] = $sql;

                return $this->createMock(PDOStatement::class);
            });

        $this->subject->updateAllCounts();

        $albumCount = array_first(array_filter($statements, static fn(string $sql): bool => str_contains($sql, '`artist`.`album_count` = ')));
        $songCount  = array_first(array_filter($statements, static fn(string $sql): bool => str_contains($sql, '`artist`.`song_count` = ')));

        self::assertStringContainsString('`album`.`enabled` = 1', $albumCount);
        self::assertStringContainsString('`song`.`enabled` = 1', $songCount);
    }

    /**
     * The album count named `song`.`enabled` in a statement that joins no `song` table, so it failed on every
     * run and the column simply never moved. Nothing above it noticed: the sweep carries on by design.
     */
    public function testUpdateAllCountsOnlyFiltersOnTablesItJoins(): void
    {
        $this->assertEveryStatementFiltersOnATableItJoins(function (): void {
            $this->subject->updateAllCounts();
        });
    }

    public function testUpdateAllSkipCountsZeroesAnArtistWithNoSkipsBeforeRollingTheRestUp(): 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();

        // the rollup is a join, so an artist whose last skip was deleted is only reached by the first statement
        self::assertStringContainsString('`total_skip` = 0', $calls[0]);
        self::assertStringContainsString('`artist_map`.`artist_id` AS `artist_id`', $calls[1]);
    }

    /**
     * The per-artist refresh grew the same defect as the sweep, twice: it is the one a caller reaches after
     * a withdrawal, so it has to hold on its own.
     */
    public function testUpdateCountsOnlyFiltersOnTablesItJoins(): void
    {
        $this->assertEveryStatementFiltersOnATableItJoins(function (): void {
            $this->subject->updateCounts(666);
        });
    }

    public function testUpdateInfoStampsTheManualFlagAsAnInt(): void
    {
        // `manual_update` is a tinyint and PDO binds false as an empty string, which MySQL rejects for the column
        $this->connection->expects(static::once())
            ->method('query')
            ->with(
                'UPDATE `artist` SET `summary` = ?, `placeformed` = ?, `yearformed` = ?, `last_update` = ?, `manual_update` = ?, `lastfm_url` = ? WHERE `id` = ?',
                ['some-summary', 'some-place', 1999, 123456, 0, null, 666]
            );

        $this->subject->updateInfo(666, 'some-summary', 'some-place', 1999, 123456, false);
    }

    public function testUpdateInfoStoresTheLastFmUrl(): void
    {
        // the url is cached with the rest of the info, so a refresh inside six months still has one to serve
        $this->connection->expects(static::once())
            ->method('query')
            ->with(
                'UPDATE `artist` SET `summary` = ?, `placeformed` = ?, `yearformed` = ?, `last_update` = ?, `manual_update` = ?, `lastfm_url` = ? WHERE `id` = ?',
                ['some-summary', 'some-place', 1999, 123456, 0, 'https://www.last.fm/music/Some+Artist', 666]
            );

        $this->subject->updateInfo(666, 'some-summary', 'some-place', 1999, 123456, false, 'https://www.last.fm/music/Some+Artist');
    }

    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 ArtistRepository(
            $this->connection,
            $this->logger,
            $this->randomIdSampler,
        );
    }

    private function assertEveryStatementFiltersOnATableItJoins(callable $sweep): void
    {
        $statements = [];

        $this->connection->method('query')
            ->willReturnCallback(function (string $sql) use (&$statements): PDOStatement {
                $statements[] = $sql;

                return $this->createMock(PDOStatement::class);
            });

        $sweep();

        foreach ($statements as $sql) {
            foreach (['song', 'album', 'album_disk', 'artist', 'catalog'] as $table) {
                if (!str_contains($sql, sprintf('`%s`.`enabled`', $table))) {
                    continue;
                }

                // the tell: a table can only be filtered on where the statement actually brought it in
                self::assertMatchesRegularExpression(
                    sprintf('/(FROM|JOIN) `%s`/', $table),
                    $sql,
                    sprintf('a statement filters on `%s`.`enabled` without joining `%s`', $table, $table)
                );
            }
        }
    }

    /**
     * @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;
    }
}