tests/Repository/TagRepositoryTest.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\CatalogCounterInterface;
use Ampache\Module\Database\DatabaseConnectionInterface;
use Ampache\Repository\Model\TagCountTypeEnum;
use PDOStatement;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use SEEC\PhpUnit\Helper\ConsecutiveParams;
class TagRepositoryTest extends TestCase
{
use ConsecutiveParams;
private CatalogCounterInterface&MockObject $catalogCounter;
private DatabaseConnectionInterface&MockObject $connection;
private TagRepository $subject;
public function testAddMapReturnsTheExistingIdWhenTheMapWasAlreadyThere(): void
{
$statement = $this->createMock(PDOStatement::class);
$statement->method('rowCount')
->willReturn(0);
$this->connection->expects(static::once())
->method('query')
->with(
'INSERT IGNORE INTO `tag_map` (`tag_id`, `user`, `object_type`, `object_id`) VALUES (?, ?, ?, ?)',
[666, 0, 'song', 42]
)
->willReturn($statement);
$this->connection->expects(static::once())
->method('fetchOne')
->with(
'SELECT `id` FROM `tag_map` WHERE `tag_id` = ? AND `user` = ? AND `object_type` = ? AND `object_id` = ?',
[666, 0, 'song', 42]
)
->willReturn('7');
// a merge-redirected or concurrent re-add must not throw InsertIdInvalidException
$this->connection->expects(static::never())
->method('getLastInsertedId');
self::assertSame(7, $this->subject->addMap(666, 'song', 42, 0));
}
public function testAddMapReturnsTheNewMapId(): void
{
$statement = $this->createMock(PDOStatement::class);
$statement->method('rowCount')
->willReturn(1);
$this->connection->expects(static::once())
->method('query')
->with(
'INSERT IGNORE INTO `tag_map` (`tag_id`, `user`, `object_type`, `object_id`) VALUES (?, ?, ?, ?)',
[666, 0, 'song', 42]
)
->willReturn($statement);
$this->connection->expects(static::once())
->method('getLastInsertedId')
->willReturn(7);
self::assertSame(7, $this->subject->addMap(666, 'song', 42, 0));
}
public function testCollectGarbageSweepsEveryMappableTypeAndRecountsAfterwards(): void
{
$statements = [];
$this->connection->method('query')
->willReturnCallback(function (string $sql) use (&$statements): PDOStatement {
$statements[] = $sql;
return $this->createMock(PDOStatement::class);
});
$this->subject->collectGarbage();
// a map is orphaned by whichever object it names, and a type with no sweep keeps the tag alive for ever
foreach (['album', 'album_disk', 'artist', 'catalog', 'label', 'live_stream', 'playlist', 'podcast', 'podcast_episode', 'search', 'song', 'tag', 'user', 'video'] as $objectType) {
self::assertNotEmpty(
array_filter(
$statements,
static fn(string $sql): bool => str_starts_with($sql, 'DELETE FROM `tag_map`') && str_contains($sql, sprintf("`tag_map`.`object_type`='%s'", $objectType))
),
sprintf('no orphaned map sweep for %s', $objectType)
);
}
// a write for a type the enum does not hold is truncated to the error value, and no other sweep can resolve it
self::assertNotEmpty(
array_filter($statements, static fn(string $sql): bool => str_starts_with($sql, 'DELETE FROM `tag_map`') && str_contains($sql, "`object_type` = ''"))
);
// the owner is part of `unique_tag_map`, so the duplicate sweep must not take the map a user set beside the one from the file
self::assertNotEmpty(
array_filter(
$statements,
static fn(string $sql): bool => str_starts_with($sql, 'DELETE `b` FROM `tag_map`') && str_contains($sql, '`a`.`user` <=> `b`.`user`')
)
);
self::assertCount(4 + 4, array_filter($statements, static fn(string $sql): bool => str_starts_with($sql, 'UPDATE `tag`')));
}
public function testCreateReturnsTheExistingIdWhenTheNameWasTakenWhileWeInserted(): void
{
$statement = $this->createMock(PDOStatement::class);
$statement->method('rowCount')
->willReturn(0);
$this->connection->expects(static::once())
->method('query')
->with('INSERT IGNORE INTO `tag` (`name`) VALUES (?)', ['some-genre'])
->willReturn($statement);
$this->connection->expects(static::once())
->method('fetchOne')
->with('SELECT `id` FROM `tag` WHERE `name` = ?', ['some-genre'])
->willReturn('42');
// the row belongs to whoever inserted it first, so nothing was added and the counter must not be touched
$this->connection->expects(static::never())
->method('getLastInsertedId');
$this->catalogCounter->expects(static::never())
->method('count');
self::assertSame(42, $this->subject->create('some-genre'));
}
public function testCreateTakesTheInsertIdBeforeTheCounterRunsItsOwnQueries(): void
{
$calls = [];
$statement = $this->createMock(PDOStatement::class);
$statement->method('rowCount')
->willReturn(1);
$this->connection->expects(static::once())
->method('query')
->with('INSERT IGNORE INTO `tag` (`name`) VALUES (?)', ['some-genre'])
->willReturnCallback(function () use (&$calls, $statement): PDOStatement {
$calls[] = 'insert';
return $statement;
});
$this->connection->expects(static::once())
->method('getLastInsertedId')
->willReturnCallback(function () use (&$calls): int {
$calls[] = 'id';
return 42;
});
$this->catalogCounter->expects(static::once())
->method('count')
->willReturnCallback(function () use (&$calls): int {
$calls[] = 'count';
return 1;
});
self::assertSame(42, $this->subject->create('some-genre'));
self::assertSame(['insert', 'id', 'count'], $calls);
}
public function testDecrementCountRefusesToGoNegative(): void
{
$this->connection->expects(static::once())
->method('query')
->with('UPDATE `tag` SET `song` = `song` - 1 WHERE `id` = ? AND `song` > 0;', [666]);
$this->subject->decrementCount(666, TagCountTypeEnum::SONG);
}
public function testDeleteRemovesTheMapsAndMergesBeforeTheTag(): void
{
$this->connection->expects(static::exactly(3))
->method('query')
->with(
...self::withConsecutive(
['DELETE FROM `tag_map` WHERE `tag_map`.`tag_id` = ?', [666]],
['DELETE FROM `tag_merge` WHERE `tag_merge`.`tag_id` = ?', [666]],
['DELETE FROM `tag` WHERE `tag`.`id` = ? ', [666]],
)
);
$this->subject->delete(666);
}
public function testFindIdByNameReturnsNullWhenTheTagIsUnknown(): void
{
$this->connection->method('fetchOne')->willReturn(false);
self::assertNull($this->subject->findIdByName('some-tag'));
}
public function testGetSongTagNamesByAlbumReadsTheGenresOfItsSongs(): void
{
$result = $this->createMock(PDOStatement::class);
$this->connection->expects(static::once())
->method('query')
->with(
"SELECT `tag`.`name` FROM `tag` JOIN `tag_map` ON `tag`.`id` = `tag_map`.`tag_id` JOIN `song` ON `tag_map`.`object_id` = `song`.`id` WHERE `song`.`album` = ? AND `tag_map`.`object_type` = 'song' GROUP BY `tag`.`id`, `tag`.`name`;",
[666]
)
->willReturn($result);
$result->expects(static::exactly(2))
->method('fetchColumn')
->willReturn('Rock', false);
self::assertSame(['Rock'], $this->subject->getSongTagNamesByAlbum(666));
}
public function testGetSongTagNamesByArtistGoesThroughTheArtistMap(): void
{
$result = $this->createMock(PDOStatement::class);
$this->connection->expects(static::once())
->method('query')
->with(
self::stringContains("`song`.`id` IN (SELECT `object_id` FROM `artist_map` WHERE `artist_id` = ? AND `object_type` = 'song')"),
[42]
)
->willReturn($result);
$result->expects(static::once())
->method('fetchColumn')
->willReturn(false);
self::assertSame([], $this->subject->getSongTagNamesByArtist(42));
}
public function testGetTopTagsGroupsTheMapsOfOneTagIntoASingleRow(): void
{
$result = $this->createMock(PDOStatement::class);
$this->connection->expects(static::once())
->method('query')
->with(
self::callback(
static fn(string $sql): bool => str_contains($sql, 'MAX(`tag_map`.`user`) AS `user`') && str_contains($sql, 'GROUP BY `tag`.`id`')
),
['song', 42]
)
->willReturn($result);
$result->method('fetch')->willReturn(false);
self::assertSame([], $this->subject->getTopTags('song', 42, 0));
}
public function testIncrementCountWritesTheColumnFromTheEnum(): void
{
$this->connection->expects(static::once())
->method('query')
->with('UPDATE `tag` SET `album` = `album` + 1 WHERE `id` = ?', [666]);
$this->subject->incrementCount(666, TagCountTypeEnum::ALBUM);
}
public function testMapExistsCountsAMergedTagAsAMatch(): void
{
$this->connection->expects(static::once())
->method('fetchRow')
->with(
'SELECT * FROM `tag_map` LEFT JOIN `tag` ON `tag`.`id` = `tag_map`.`tag_id` LEFT JOIN `tag_merge` ON `tag`.`id`=`tag_merge`.`tag_id` WHERE (`tag_map`.`tag_id` = ? OR `tag_map`.`tag_id` = `tag_merge`.`merged_to`) AND `tag_map`.`user` = ? AND `tag_map`.`object_id` = ? AND `tag_map`.`object_type` = ?',
[666, 0, 42, 'song']
)
->willReturn(['id' => '7']);
self::assertTrue($this->subject->mapExists('song', 42, 666, 0));
}
public function testMapExistsReturnsFalseWithoutARow(): void
{
$this->connection->method('fetchRow')->willReturn(false);
self::assertFalse($this->subject->mapExists('song', 42, 666, 0));
}
public function testMergeIntoBindsBothTagIdsRatherThanInterpolatingThem(): void
{
$this->connection->expects(static::once())
->method('query')
->with(
'REPLACE INTO `tag_map` (`tag_id`, `user`, `object_type`, `object_id`) SELECT ?, `user`, `object_type`, `object_id` FROM `tag_map` AS `tm` WHERE `tm`.`tag_id` = ? AND NOT EXISTS (SELECT 1 FROM `tag_map` WHERE `tag_map`.`tag_id` = ? AND `tag_map`.`object_id` = `tm`.`object_id` AND `tag_map`.`object_type` = `tm`.`object_type` AND `tag_map`.`user` = `tm`.`user`)',
[42, 666, 42]
);
$this->subject->mergeInto(666, 42);
}
public function testMigrateMapsKeepsWhatTheTargetAlreadyHad(): void
{
// a user who already tagged both sides keeps the tag instead of the destination row being silently dropped
$this->connection->expects(static::exactly(2))
->method('query')
->with(
...self::withConsecutive(
[
'INSERT IGNORE INTO `tag_map` (`tag_id`, `object_id`, `object_type`, `user`) SELECT `old_map`.`tag_id`, ?, `old_map`.`object_type`, `old_map`.`user` FROM `tag_map` AS `old_map` WHERE `old_map`.`object_type` = ? AND `old_map`.`object_id` = ?',
[42, 'song', 666],
],
[
'DELETE FROM `tag_map` WHERE `object_type` = ? AND `object_id` = ?',
['song', 666],
],
)
);
$this->subject->migrateMaps('song', 666, 42);
}
public function testSetHiddenWritesTheFlagAloneOtherwise(): void
{
$this->connection->expects(static::once())
->method('query')
->with('UPDATE `tag` SET `is_hidden` = ? WHERE `id` = ?', [0, 666]);
$this->subject->setHidden(666, 0, false);
}
public function testSetHiddenZeroesTheCountersOnlyWhenAsked(): void
{
$this->connection->expects(static::once())
->method('query')
->with('UPDATE `tag` SET `is_hidden` = ?, `artist` = 0, `album` = 0, `song` = 0 WHERE `id` = ?', [1, 666]);
$this->subject->setHidden(666, 1, true);
}
protected function setUp(): void
{
$this->connection = $this->createMock(DatabaseConnectionInterface::class);
$this->catalogCounter = $this->createMock(CatalogCounterInterface::class);
$this->subject = new TagRepository(
$this->connection,
$this->catalogCounter
);
}
}