tests/Repository/SongRepositoryTest.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\Module\Catalog\Catalog;
use Ampache\Module\Database\DatabaseConnectionInterface;
use Ampache\Module\Database\Exception\QueryFailedException;
use Ampache\Repository\Model\SongDataFieldEnum;
use Ampache\Repository\Model\SongFieldEnum;
use PDO;
use PDOStatement;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use SEEC\PhpUnit\Helper\ConsecutiveParams;
class SongRepositoryTest extends TestCase
{
use ConsecutiveParams;
private DatabaseConnectionInterface&MockObject $connection;
private LoggerInterface&MockObject $logger;
private SongRepository $subject;
public function testCollectGarbageForSongsSkipsAnEmptyList(): void
{
$this->connection->expects(static::never())
->method('query');
$this->subject->collectGarbageForSongs([]);
}
public function testCollectGarbageForSongsSwallowsAFailedStatement(): void
{
$this->connection->expects(static::once())
->method('query')
->with("DELETE FROM `artist_map` WHERE `artist_map`.`object_type` = 'song' AND `artist_map`.`object_id` IN (42,666);")
->willThrowException(new QueryFailedException());
$this->subject->collectGarbageForSongs([42, 666]);
}
public function testDeleteByCatalogReportsAFailedDelete(): void
{
$this->connection->expects(static::once())
->method('query')
->with('DELETE FROM `song` WHERE `catalog` = ?', [7])
->willThrowException(new QueryFailedException('nope'));
self::assertFalse($this->subject->deleteByCatalog(7));
}
public function testDeleteByIdsCastsEveryIdAndSkipsAnEmptyList(): void
{
$this->connection->expects(static::once())
->method('query')
->with('DELETE FROM `song` WHERE `id` IN (1,0,3)');
$this->subject->deleteByIds([1, 'x', 3]);
$this->subject->deleteByIds([]);
}
public function testDeleteByIdsWithArchiveReturnsNothingForAnEmptyList(): void
{
$this->connection->expects(static::never())
->method('query');
self::assertSame(['albums' => [], 'artists' => []], $this->subject->deleteByIdsWithArchive([]));
}
public function testDeleteByIdsWithArchiveReturnsTheDistinctAlbumsAndArtistsThatLostASong(): void
{
$selectResult = $this->createMock(PDOStatement::class);
$selectResult->expects(static::exactly(4))
->method('fetch')
->with(PDO::FETCH_ASSOC)
->willReturn(
['album' => '1', 'artist' => '10'],
['album' => '1', 'artist' => '10'],
['album' => '2', 'artist' => '0'],
false
);
$this->connection->expects(static::exactly(3))
->method('query')
->with(
...self::withConsecutive(
['SELECT DISTINCT `album`, `artist` FROM `song` WHERE `id` IN (1,2);'],
['REPLACE INTO `deleted_song` (`id`, `addition_time`, `delete_time`, `title`, `file`, `catalog`, `total_count`, `total_skip`, `album`, `artist`) SELECT `id`, `addition_time`, UNIX_TIMESTAMP(), `title`, `file`, `catalog`, `total_count`, `total_skip`, `album`, `artist` FROM `song` WHERE `id` IN (1,2);'],
['DELETE FROM `song` WHERE `id` IN (1,2);']
)
)
->willReturn($selectResult);
self::assertSame(
['albums' => [1, 2], 'artists' => [10]],
$this->subject->deleteByIdsWithArchive([1, 2])
);
}
public function testDeleteRecordsTheDeletionAndRemovesTheSong(): void
{
$songId = 666;
$this->connection->expects(static::exactly(2))
->method('query')
->with(
...self::withConsecutive(
[
'REPLACE INTO `deleted_song` (`id`, `addition_time`, `delete_time`, `title`, `file`, `catalog`, `total_count`, `total_skip`, `album`, `artist`) SELECT `id`, `addition_time`, UNIX_TIMESTAMP(), `title`, `file`, `catalog`, `total_count`, `total_skip`, `album`, `artist` FROM `song` WHERE `id` = ?;',
[$songId],
],
[
'DELETE FROM `song` WHERE `id` = ?',
[$songId],
]
)
);
self::assertTrue($this->subject->delete($songId));
}
public function testDeleteReturnsFalseWhenTheSongCouldNotBeRemoved(): void
{
$calls = 0;
// the deleted_song record is best effort, so only the delete itself decides the result
$this->connection->expects(static::exactly(2))
->method('query')
->willReturnCallback(function () use (&$calls): PDOStatement {
$calls++;
if ($calls === 2) {
throw new QueryFailedException();
}
return $this->createMock(PDOStatement::class);
});
self::assertFalse($this->subject->delete(666));
}
public function testFindIdByFilePatternTakesTheFirstMatch(): void
{
$this->connection->expects(static::once())
->method('fetchOne')
->with('SELECT `id` FROM `song` WHERE `file` LIKE ? LIMIT 1', ['http://host/play%oid=7&%'])
->willReturn('666');
self::assertSame(666, $this->subject->findIdByFilePattern('http://host/play%oid=7&%'));
}
public function testFindIdsWithMissingAlbumReadsBothTheStaleMapAndTheMissingRow(): void
{
$result = $this->createMock(PDOStatement::class);
$this->connection->expects(static::once())
->method('query')
->with('SELECT `id` FROM `song` WHERE (`song`.`album` IN (SELECT `album_id` FROM `album_map` WHERE `album_id` NOT IN (SELECT `id` FROM `album`)) OR `song`.`album` NOT IN (SELECT `id` FROM `album`));')
->willReturn($result);
$result->expects(static::exactly(2))
->method('fetchColumn')
->willReturn('666', false);
self::assertSame([666], $this->subject->findIdsWithMissingAlbum());
}
public function testFindOwnerIdReturnsFalseWhenTheSongDoesNotExist(): void
{
$this->connection->method('fetchRow')->willReturn(false);
self::assertFalse($this->subject->findOwnerId(666));
}
public function testFindOwnerIdReturnsNullWhenTheSongWasNotUploaded(): void
{
// distinct from the missing-song case below: this row exists, so an owner check may still downgrade
$this->connection->method('fetchRow')->willReturn(['user_upload' => null]);
self::assertNull($this->subject->findOwnerId(666));
}
public function testFindOwnerIdReturnsTheUploader(): void
{
$this->connection->expects(static::once())
->method('fetchRow')
->with('SELECT `user_upload` FROM `song` WHERE `id` = ?', [666])
->willReturn(['user_upload' => '42']);
self::assertSame(42, $this->subject->findOwnerId(666));
}
public function testGetByCatalogReturnsAllItems(): void
{
$songId = 666;
$result = $this->createMock(PDOStatement::class);
$this->connection->expects(static::once())
->method('query')
->with(
'SELECT `id` FROM `song` ORDER BY `album`, `track`',
)
->willReturn($result);
$result->expects(static::exactly(2))
->method('fetchColumn')
->willReturn((string) $songId, false);
self::assertSame(
[$songId],
iterator_to_array($this->subject->getByCatalog())
);
}
public function testGetByCatalogReturnsValuesForCatalog(): void
{
$songId = 666;
$catalogId = 42;
$result = $this->createMock(PDOStatement::class);
$catalog = $this->createMock(Catalog::class);
$catalog->expects(static::once())
->method('getId')
->willReturn($catalogId);
$this->connection->expects(static::once())
->method('query')
->with(
'SELECT `id` FROM `song` WHERE `catalog` = ? ORDER BY `album`, `track`',
[$catalogId]
)
->willReturn($result);
$result->expects(static::exactly(2))
->method('fetchColumn')
->willReturn((string) $songId, false);
self::assertSame(
[$songId],
iterator_to_array($this->subject->getByCatalog($catalog))
);
}
public function testGetByLicenseReturnsSongIds(): void
{
$result = $this->createMock(PDOStatement::class);
$this->connection->expects(static::once())
->method('query')
->with(
'SELECT `id` FROM `song` WHERE `song`.`license` = ?',
[42]
)
->willReturn($result);
$result->expects(static::exactly(3))
->method('fetchColumn')
->willReturn('666', '33', false);
self::assertSame(
[666, 33],
$this->subject->getByLicense(42)
);
}
public function testGetEnabledIdsByCatalogOrdersOnlyWhenAsked(): void
{
$result = $this->createMock(PDOStatement::class);
$this->connection->expects(static::once())
->method('query')
->with(
"SELECT `id` FROM `song` WHERE `catalog` = ? AND `enabled` = '1' ",
[7]
)
->willReturn($result);
$result->expects(static::exactly(2))
->method('fetchColumn')
->willReturn('666', false);
self::assertSame([666], $this->subject->getEnabledIdsByCatalog(7));
}
public function testGetEnabledIdsJoinsTheCatalogOnlyWhenDisabledCatalogsAreHidden(): void
{
$result = $this->createMock(PDOStatement::class);
$this->connection->expects(static::once())
->method('query')
->with("SELECT `song`.`id` FROM `song` LEFT JOIN `catalog` ON `catalog`.`id` = `song`.`catalog` WHERE `song`.`enabled` = '1' AND `catalog`.`enabled` = '1' AND `song`.`catalog` IN (1,0) ORDER BY `song`.`album`, `song`.`id` LIMIT 10")
->willReturn($result);
$result->expects(static::once())
->method('fetchColumn')
->willReturn(false);
self::assertSame([], $this->subject->getEnabledIds([1, 'x9'], 10, 0, true));
}
public function testGetFileRowsByCatalogCarriesTheTitleAVerifyCompares(): void
{
$result = $this->createMock(PDOStatement::class);
$this->connection->expects(static::once())
->method('query')
->with('SELECT `id`, `file`, `title` FROM `song` WHERE `catalog` = ?', [7])
->willReturn($result);
$result->expects(static::exactly(2))
->method('fetch')
->willReturn(['id' => '1', 'file' => '/a.mp3', 'title' => 'A'], false);
self::assertSame([['id' => 1, 'file' => '/a.mp3', 'title' => 'A']], $this->subject->getFileRowsByCatalog(7));
}
public function testGetFilesByCatalogKeysTheFilesBySongId(): void
{
$result = $this->createMock(PDOStatement::class);
$this->connection->expects(static::once())
->method('query')
->with(
'SELECT `id`, `file` FROM `song` WHERE `catalog` = ? AND `file` IS NOT NULL ORDER BY `id` DESC;',
[7]
)
->willReturn($result);
$result->expects(static::exactly(2))
->method('fetch')
->willReturn(['id' => '666', 'file' => '/music/song.mp3'], false);
self::assertSame([666 => '/music/song.mp3'], $this->subject->getFilesByCatalog(7));
}
public function testGetIdsByFilePrefixBindsTheWildcard(): void
{
$result = $this->createMock(PDOStatement::class);
$this->connection->expects(static::once())
->method('query')
->with(
'SELECT `id` FROM `song` WHERE `file` LIKE ?',
["/music/o'brien%"]
)
->willReturn($result);
$result->expects(static::once())
->method('fetchColumn')
->willReturn(false);
self::assertSame([], $this->subject->getIdsByFilePrefix("/music/o'brien"));
}
public function testGetIdsMissingArtOnlyReturnsEnabledSongsWithoutOriginalArt(): void
{
$result = $this->createMock(PDOStatement::class);
$this->connection->expects(static::once())
->method('query')
->with(
"SELECT `song`.`id` FROM `song` LEFT JOIN `image` ON `image`.`object_id` = `song`.`id` AND `image`.`object_type` = 'song' AND `image`.`size` = 'original' WHERE `song`.`catalog` = ? AND `song`.`enabled` = '1' AND `image`.`object_id` IS NULL",
[7]
)
->willReturn($result);
$result->expects(static::exactly(2))
->method('fetchColumn')
->willReturn('666', false);
self::assertSame([666], $this->subject->getIdsMissingArt(7));
}
public function testGetSongMapValuesBulkDoesNothingForNoSongs(): void
{
$this->connection->expects(static::never())
->method('query');
self::assertSame([], $this->subject->getSongMapValuesBulk([], 'isrc'));
}
public function testGetSongMapValuesBulkKeysTheValuesBySongAndKeepsTheEmptyOnes(): void
{
$result = $this->createMock(PDOStatement::class);
$this->connection->expects(static::once())
->method('query')
->with(
'SELECT DISTINCT `song_id`, `object_id` FROM `song_map` WHERE `object_type` = ? AND `song_id` IN (?,?)',
['isrc', 1, 2]
)
->willReturn($result);
$result->method('fetch')->willReturnOnConsecutiveCalls(
['song_id' => '2', 'object_id' => 'FRXXX0000001'],
false
);
self::assertSame(
[1 => [], 2 => ['FRXXX0000001']],
$this->subject->getSongMapValuesBulk([1, 2], 'isrc')
);
}
public function testPruneDeletedHistoryDeletesOlderRows(): void
{
$this->connection->expects(static::once())
->method('query')
->with(
'DELETE FROM `deleted_song` WHERE `delete_time` < (UNIX_TIMESTAMP() - (? * 86400));',
[365]
);
$this->subject->pruneDeletedHistory(365);
}
public function testPruneDeletedHistorySkipsWhenDaysIsNotPositive(): void
{
$this->connection->expects(static::never())
->method('query');
$this->subject->pruneDeletedHistory(0);
$this->subject->pruneDeletedHistory(-1);
}
public function testResetCountsWithoutHistoryMovesEveryCounterAgainstItsOwnCountType(): 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->resetCountsWithoutHistory();
// a rebuild join cannot reach a song whose rows are gone, so this is the only thing that moves it
self::assertStringContainsString('`total_count` = 0', $calls[0]);
self::assertStringContainsString("`count_type` = 'stream'", $calls[0]);
self::assertStringContainsString('`total_skip` = 0', $calls[1]);
self::assertStringContainsString("`count_type` = 'skip'", $calls[1]);
self::assertSame('UPDATE `song` SET `played` = 0 WHERE `played` = 1 AND `total_count` = 0;', $calls[2]);
// and back to played when the history is there, which video and podcast_episode always had
self::assertStringContainsString('`played` = 1', $calls[3]);
}
public function testSetDataFieldReturnsFalseWhenTheWriteFailed(): void
{
$this->connection->expects(static::once())
->method('query')
->willThrowException(new QueryFailedException('some-error'));
self::assertFalse($this->subject->setDataField(666, SongDataFieldEnum::LYRICS, 'some-lyrics'));
}
public function testSetDataFieldWritesToSongDataKeyedBySongId(): void
{
$this->connection->expects(static::once())
->method('query')
->with('UPDATE `song_data` SET `comment` = ? WHERE `song_id` = ?', ['some-comment', 666]);
self::assertTrue($this->subject->setDataField(666, SongDataFieldEnum::COMMENT, 'some-comment'));
}
public function testSetFieldAcceptsANullValue(): void
{
$this->connection->expects(static::once())
->method('query')
->with('UPDATE `song` SET `license` = ? WHERE `id` = ?', [null, 666]);
self::assertTrue($this->subject->setField(666, SongFieldEnum::LICENSE, null));
}
public function testSetFieldReturnsFalseWhenTheWriteFailed(): void
{
// the model's callers branch on this, so the exception must not escape as a fatal
$this->connection->expects(static::once())
->method('query')
->willThrowException(new QueryFailedException('some-error'));
self::assertFalse($this->subject->setField(666, SongFieldEnum::TITLE, 'some-title'));
}
public function testSetFieldWritesTheColumnFromTheEnum(): void
{
$this->connection->expects(static::once())
->method('query')
->with('UPDATE `song` SET `title` = ? WHERE `id` = ?', ['some-title', 666]);
self::assertTrue($this->subject->setField(666, SongFieldEnum::TITLE, 'some-title'));
}
public function testSetFieldWritesTheFileColumn(): void
{
$this->connection->expects(static::once())
->method('query')
->with('UPDATE `song` SET `file` = ? WHERE `id` = ?', ['/new/path.mp3', 666]);
self::assertTrue($this->subject->setField(666, SongFieldEnum::FILE, '/new/path.mp3'));
}
public function testUpdateAllCountsRunsTheThreeSongSweeps(): void
{
$calls = [];
$this->connection->expects(static::exactly(3))
->method('query')
->willReturnCallback(function (string $sql) use (&$calls): PDOStatement {
$calls[] = $sql;
return $this->createMock(PDOStatement::class);
});
$this->subject->updateAllCounts();
foreach ($calls as $sql) {
self::assertStringStartsWith('UPDATE `song`', $sql);
}
}
protected function setUp(): void
{
$this->connection = $this->createMock(DatabaseConnectionInterface::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->subject = new SongRepository(
$this->connection,
$this->logger,
);
}
}