tests/Repository/FolderRepositoryTest.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.
*
*/
namespace Ampache\Repository;
use Ampache\Config\AmpConfig;
use Ampache\Module\Database\DatabaseConnectionInterface;
use Ampache\Module\Database\Exception\QueryFailedException;
use Ampache\Repository\Model\Folder;
use Ampache\Repository\Model\LibraryItemEnum;
use org\bovigo\vfs\vfsStream;
use PDO;
use PDOStatement;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
class FolderRepositoryTest extends TestCase
{
private DatabaseConnectionInterface&MockObject $connection;
private LoggerInterface&MockObject $logger;
private FolderRepository $subject;
/**
* The sweep calls a folder empty when nothing in the map points at it, and the map is told about a folder
* only on a rebuild: an artist directory holding nothing but its albums was deleted, orphaning every one
*/
public function testAFolderHoldingOnlyFoldersIsNotTreatedAsEmpty(): void
{
$statements = [];
$this->connection->method('query')
->willReturnCallback(function (string $sql) use (&$statements): PDOStatement {
$statements[] = $sql;
$result = $this->createMock(PDOStatement::class);
$result->method('fetch')->willReturn(false);
return $result;
});
$this->subject->collectGarbage();
$prune = array_filter($statements, static fn(string $sql): bool => str_contains($sql, 'SELECT `id`, `path_name` FROM `folder` WHERE `user` IS NULL'));
self::assertCount(1, $prune);
self::assertStringContainsString(
'AND `id` NOT IN (SELECT `parent` FROM `folder` WHERE `parent` IS NOT NULL)',
(string) current($prune)
);
}
/**
* A file browser lists its folders first and everything in name order, which the listing never asked for
*/
public function testASubFolderListingIsOrderedFoldersFirst(): void
{
$result = $this->createMock(PDOStatement::class);
$this->connection->expects(static::once())
->method('query')
->with(self::stringContains('ORDER BY `sort_group`, `name`'))
->willReturn($result);
$result->method('fetch')->willReturn(false);
$this->subject->getObjects(7, 5);
}
/**
* `folder_map` only learns about a folder when the whole map is rebuilt, so a listing that read it alone
* showed nothing of anything scanned since: the sub-folders are read from `folder` itself, as the root is
*/
public function testASubFolderListingReadsTheFolderTable(): void
{
$result = $this->createMock(PDOStatement::class);
$this->connection->expects(static::once())
->method('query')
->with(
self::logicalAnd(
self::stringContains('FROM `folder` WHERE `parent` = ?'),
self::stringContains("`object_type` != 'folder'")
)
)
->willReturn($result);
$result->method('fetch')->willReturn(false);
$this->subject->getObjects(7, 5);
}
public function testCollectGarbageRunsCleanupQueries(): void
{
$this->connection->expects(static::atLeast(7))
->method('query');
$this->subject->collectGarbage();
}
public function testCollectGarbageSwallowsDatabaseException(): void
{
$this->connection->method('query')
->willThrowException(new QueryFailedException('boom'));
$this->subject->collectGarbage();
$this->addToAssertionCount(1);
}
public function testGetAllReturnsFoldersKeyedById(): void
{
$result = $this->createMock(PDOStatement::class);
$this->connection->expects(static::once())
->method('query')
->with('SELECT `id`, `name` FROM `folder`')
->willReturn($result);
$result->expects(static::exactly(3))
->method('fetch')
->with(PDO::FETCH_ASSOC)
->willReturnOnConsecutiveCalls(
['id' => '1', 'name' => 'Music'],
['id' => '2', 'name' => 'Podcasts'],
false,
);
self::assertSame(
[1 => 'Music', 2 => 'Podcasts'],
$this->subject->getAll(),
);
}
public function testGetByCatalogKeyedByPathNameLowercasesTheKey(): void
{
$result = $this->createMock(PDOStatement::class);
$this->connection->expects(static::once())
->method('query')
->with(
'SELECT `id`, `path_name` FROM `folder` WHERE `catalog` = ? AND `path_name` IS NOT NULL;',
[7]
)
->willReturn($result);
$result->expects(static::exactly(2))
->method('fetch')
->willReturn(['id' => '4', 'path_name' => '/Music/Some Artist'], false);
self::assertSame(
['/music/some artist' => 4],
$this->subject->getByCatalogKeyedByPathName(7)
);
}
/**
* A catalog scanned for folders once but added to since (a plain "Add" never touches `folder_map`)
* still has folder rows of its own, so a catalog with any unmapped song is excluded rather than
* contributing a folder tree with gaps in it
*/
public function testGetCatalogIdsWithFolderMapExcludesACatalogWithUnmappedMedia(): void
{
$result = $this->createMock(PDOStatement::class);
$this->connection->expects(static::once())
->method('query')
->with(
self::logicalAnd(
self::stringContains("`file` NOT LIKE 'http://%'"),
self::stringContains("`file` NOT LIKE 'https://%'"),
),
[3, 5, 3, 5, 3, 5]
)
->willReturn($result);
$result->expects(static::exactly(2))
->method('fetch')
->willReturnOnConsecutiveCalls(['catalog' => '5'], false);
self::assertSame([3], $this->subject->getCatalogIdsWithFolderMap([3, 5]));
}
public function testGetCatalogIdsWithFolderMapReturnsEmptyArrayForEmptyInput(): void
{
$this->connection->expects(static::never())->method('query');
self::assertSame([], $this->subject->getCatalogIdsWithFolderMap([]));
}
/**
* folder_map rows carry their own `catalog` column, so a folder listing must honor the opt-in
* `catalog_filter` feature the same way every other browse type does -- folders were the one type
* this filter never reached
*/
public function testGetChildrenAppliesTheCatalogFilterWhenEnabled(): void
{
AmpConfig::set('catalog_filter', true, true);
try {
$result = $this->createMock(PDOStatement::class);
$this->connection->expects(static::once())
->method('query')
->with(
self::stringContains('`folder_map`.`catalog` IN (SELECT `catalog_id` FROM `catalog_filter_group_map`'),
[666]
)
->willReturn($result);
$result->method('fetch')->willReturn(false);
$this->subject->getChildren(666, 5);
} finally {
AmpConfig::set('catalog_filter', false, true);
}
}
public function testGetChildrenDropsRowsWithAnUnknownObjectType(): void
{
$result = $this->createMock(PDOStatement::class);
$this->connection->expects(static::once())
->method('query')
->with(self::stringContains('`folder_id` = ?'), [666])
->willReturn($result);
$result->expects(static::exactly(3))
->method('fetch')
->willReturnOnConsecutiveCalls(
['object_id' => '21', 'object_type' => 'song'],
['object_id' => '33', 'object_type' => 'not-a-type'],
false
);
self::assertSame(
[['object_type' => LibraryItemEnum::SONG, 'object_id' => 21]],
$this->subject->getChildren(666)
);
}
public function testGetChildrenQueriesTheRootWhenNoIdIsGiven(): void
{
$result = $this->createMock(PDOStatement::class);
$this->connection->expects(static::once())
->method('query')
->with(self::stringContains('`folder_id` IS NULL'))
->willReturn($result);
$result->expects(static::once())
->method('fetch')
->willReturn(false);
self::assertSame([], $this->subject->getChildren(null));
}
public function testGetChildrenSkipsTheCatalogFilterByDefault(): void
{
$result = $this->createMock(PDOStatement::class);
$this->connection->expects(static::once())
->method('query')
->with(self::logicalNot(self::stringContains('catalog_filter_group_map')), [666])
->willReturn($result);
$result->method('fetch')->willReturn(false);
$this->subject->getChildren(666, 5);
}
public function testGetItemCountReturnsCount(): void
{
$result = $this->createMock(PDOStatement::class);
$this->connection->expects(static::once())
->method('query')
->with('SELECT COUNT(*) AS `count` FROM `folder`;')
->willReturn($result);
$result->expects(static::once())
->method('fetch')
->with(PDO::FETCH_ASSOC)
->willReturn(['count' => '5']);
self::assertSame(5, $this->subject->getItemCount());
}
public function testGetItemCountReturnsZeroWhenNoRowFound(): void
{
$result = $this->createMock(PDOStatement::class);
$this->connection->method('query')
->willReturn($result);
$result->method('fetch')
->willReturn(false);
self::assertSame(0, $this->subject->getItemCount());
}
public function testGetMediaCountCountsEverythingBelowTheFolder(): void
{
$folder = new Folder();
$folder->id = 666;
$folder->path_name = '/some/path';
$this->connection->expects(static::once())
->method('fetchOne')
->with(
self::stringContains('`folder_map`.`path_name` LIKE ?'),
[666, '/some/path/%']
)
->willReturn('42');
self::assertSame(42, $this->subject->getMediaCount($folder));
}
public function testGetMediaCountReturnsZeroWhenNothingIsFound(): void
{
$this->connection->expects(static::once())
->method('fetchOne')
->willReturn(false);
self::assertSame(0, $this->subject->getMediaCount(new Folder()));
}
public function testGetMediasNarrowsToASingleTypeWhenAsked(): void
{
$folder = new Folder();
$folder->id = 666;
$folder->path_name = '/some/path';
$result = $this->createMock(PDOStatement::class);
$this->connection->expects(static::once())
->method('query')
->with(
self::stringContains('`folder_map`.`object_type` = ?'),
['song', 666, '/some/path/%']
)
->willReturn($result);
$result->expects(static::once())
->method('fetch')
->willReturn(false);
self::assertSame([], $this->subject->getMedias($folder, 'song'));
}
public function testGetNameByIdReturnsNullWhenThereIsNoSuchFolder(): void
{
$this->connection->expects(static::once())
->method('fetchOne')
->willReturn(false);
self::assertNull($this->subject->getNameById(666));
}
public function testGetNameByIdReturnsTheName(): void
{
$this->connection->expects(static::once())
->method('fetchOne')
->with('SELECT `folder`.`name` AS `f_name` FROM `folder` WHERE `id` = ?;', [666])
->willReturn('some-name');
self::assertSame('some-name', $this->subject->getNameById(666));
}
public function testGetObjectsAppliesTheCatalogFilterToTheRootListingWhenEnabled(): void
{
AmpConfig::set('catalog_filter', true, true);
try {
$result = $this->createMock(PDOStatement::class);
$this->connection->expects(static::once())
->method('query')
->with(self::stringContains('`folder`.`catalog` IN (SELECT `catalog_id` FROM `catalog_filter_group_map`'))
->willReturn($result);
$result->method('fetch')->willReturn(false);
$this->subject->getObjects(null, 5);
} finally {
AmpConfig::set('catalog_filter', false, true);
}
}
public function testGetObjectsListsTopLevelFoldersForTheRoot(): void
{
$result = $this->createMock(PDOStatement::class);
$this->connection->expects(static::once())
->method('query')
->with(self::stringContains('`parent` IS NULL'))
->willReturn($result);
$result->expects(static::exactly(2))
->method('fetch')
->willReturnOnConsecutiveCalls(
['object_id' => '21', 'object_type' => 'folder'],
false
);
self::assertSame(
[['object_type' => LibraryItemEnum::FOLDER, 'object_id' => 21]],
$this->subject->getObjects(null)
);
}
public function testHasChildrenReportsWhetherRowsExist(): void
{
$result = $this->createMock(PDOStatement::class);
$this->connection->expects(static::once())
->method('query')
->with('SELECT `object_id`, `object_type` FROM `folder_map` WHERE `folder_id` = ?;', [666])
->willReturn($result);
$result->expects(static::once())
->method('rowCount')
->willReturn(2);
self::assertTrue($this->subject->hasChildren(666));
}
public function testLookupByPathNameReturnsIdWhenFound(): void
{
$this->connection->expects(static::once())
->method('fetchOne')
->with(
'SELECT `id` FROM `folder` WHERE `path_name` = ? AND `catalog` = ?',
['/music', 3],
)
->willReturn('21');
self::assertSame(21, $this->subject->lookupByPathName('/music', 3));
}
public function testLookupByPathNameReturnsMinusOneForBlankPath(): void
{
$this->connection->expects(static::never())
->method('fetchOne');
self::assertSame(-1, $this->subject->lookupByPathName(''));
}
public function testLookupReturnsIdWhenFound(): void
{
$this->connection->expects(static::once())
->method('fetchOne')
->with(
'SELECT `id` FROM `folder` WHERE `name` = ? AND `catalog` = ? AND `parent` = ?',
['Music', 3, 7],
)
->willReturn('21');
self::assertSame(21, $this->subject->lookup('Music', 3, 7));
}
public function testLookupReturnsMinusOneForBlankName(): void
{
$this->connection->expects(static::never())
->method('fetchOne');
self::assertSame(-1, $this->subject->lookup(''));
}
public function testLookupReturnsZeroWhenNotFound(): void
{
$this->connection->expects(static::once())
->method('fetchOne')
->with(
'SELECT `id` FROM `folder` WHERE `name` = ? AND `catalog` = ?',
['Music', 3],
)
->willReturn(false);
self::assertSame(0, $this->subject->lookup('Music', 3));
}
public function testMigrateObjectMovesTheMapRows(): void
{
$this->connection->expects(static::once())
->method('query')
->with(
'UPDATE `folder_map` SET `object_id` = ? WHERE `object_id` = ? AND `object_type` = ?;',
[33, 21, 'song']
);
$this->subject->migrateObject('song', 21, 33);
}
public function testPersistInsertsANewFolderAndReturnsTheId(): void
{
$folder = new Folder();
$folder->name = 'some-name';
$folder->catalog = 2;
$folder->user = 42;
$folder->addition_time = 1000;
$folder->update_time = 1234;
$folder->path = '1,2';
$folder->path_name = '/some/path';
$this->connection->expects(static::once())
->method('query')
->with(
self::stringContains('INSERT INTO `folder`'),
['some-name', 2, null, 42, 1000, 1234, '1,2', '/some/path']
);
$this->connection->expects(static::once())
->method('getLastInsertedId')
->willReturn(666);
self::assertSame(666, $this->subject->persist($folder));
}
public function testPersistUpdatesAnExistingFolderAndReturnsNull(): void
{
$folder = new Folder();
$folder->id = 666;
$folder->name = 'some-name';
$folder->catalog = 2;
$folder->parent = 21;
$folder->update_time = 1234;
$this->connection->expects(static::once())
->method('query')
->with(
'UPDATE `folder` SET `name` = ?, `catalog` = ?, `parent` = ?, `update_time` = ? WHERE `id` = ?',
['some-name', 2, 21, 1234, 666]
);
$this->connection->expects(static::never())
->method('getLastInsertedId');
self::assertNull($this->subject->persist($folder));
}
/**
* `catalog_clean_empty_dirs` off only used to skip removing the directory, not the db row behind it: an
* upload catalog's pre-created, still-empty target folders were deleted by the very scan that made them
*/
public function testPruneEmptyFoldersKeepsAFolderStillOnDiskWhenRemovalIsDisabled(): void
{
$root = vfsStream::setup('catalog');
vfsStream::newDirectory('upload-target')->at($root);
$path = vfsStream::url('catalog/upload-target');
AmpConfig::set('catalog_clean_empty_dirs', false, true);
try {
$result = $this->createMock(PDOStatement::class);
$result->method('fetch')->willReturnOnConsecutiveCalls(
['id' => '42', 'path_name' => $path],
false
);
$this->connection->expects(static::once())
->method('query')
->with(self::stringContains('SELECT `id`, `path_name` FROM `folder`'))
->willReturn($result);
$this->subject->pruneEmptyFolders();
} finally {
AmpConfig::set('catalog_clean_empty_dirs', false, true);
}
}
/**
* A folder row tracking a path that is actually gone from disk is a real orphan, pruned regardless of
* `catalog_clean_empty_dirs` -- that setting only ever decided whether the directory itself is removed
*/
public function testPruneEmptyFoldersRemovesARowForAPathGoneFromDisk(): void
{
AmpConfig::set('catalog_clean_empty_dirs', false, true);
try {
$calls = [];
$this->connection->method('query')
->willReturnCallback(function (string $sql, array $params = []) use (&$calls): PDOStatement {
$calls[] = [$sql, $params];
$result = $this->createMock(PDOStatement::class);
if (str_starts_with($sql, 'SELECT `id`, `path_name` FROM `folder`') && count($calls) === 1) {
$result->method('fetch')->willReturnOnConsecutiveCalls(
['id' => '42', 'path_name' => '/does/not/exist'],
false
);
} else {
$result->method('fetch')->willReturn(false);
}
return $result;
});
$this->subject->pruneEmptyFolders();
$deletes = array_filter($calls, static fn(array $call): bool => str_starts_with($call[0], 'DELETE FROM `folder` WHERE `id`'));
self::assertNotEmpty($deletes, 'a folder whose directory is gone from disk must still be pruned');
self::assertSame([42], current($deletes)[1]);
} finally {
AmpConfig::set('catalog_clean_empty_dirs', false, true);
}
}
public function testResolveSingleArtistIdReturnsNullWhenNoSongIsMapped(): void
{
$result = $this->createMock(PDOStatement::class);
$result->expects(static::once())
->method('fetch')
->willReturn(false);
$this->connection->expects(static::once())
->method('query')
->willReturn($result);
self::assertNull($this->subject->resolveSingleArtistId($this->folderAt(666, '/some/path')));
}
public function testResolveSingleArtistIdReturnsNullWhenSongsBelongToDifferentArtists(): void
{
$result = $this->createMock(PDOStatement::class);
$result->expects(static::exactly(3))
->method('fetch')
->willReturnOnConsecutiveCalls(['artist' => '7'], ['artist' => '9'], false);
$this->connection->expects(static::once())
->method('query')
->willReturn($result);
self::assertNull($this->subject->resolveSingleArtistId($this->folderAt(666, '/some/path')));
}
public function testResolveSingleArtistIdReturnsTheArtistEverySongBelongsTo(): void
{
$result = $this->createMock(PDOStatement::class);
$result->expects(static::exactly(2))
->method('fetch')
->willReturnOnConsecutiveCalls(['artist' => '7'], false);
$this->connection->expects(static::once())
->method('query')
->with(
self::stringContains('`folder_map`.`path_name` LIKE ?'),
[666, '/some/path/%']
)
->willReturn($result);
self::assertSame(7, $this->subject->resolveSingleArtistId($this->folderAt(666, '/some/path')));
}
public function testUpdateFolderCountsGivesEveryAncestorTheTotalOfItsSubtree(): void
{
$direct = $this->createMock(PDOStatement::class);
$tree = $this->createMock(PDOStatement::class);
$writes = [];
$this->connection->method('query')
->willReturnCallback(function (string $sql, array $params = []) use ($direct, $tree, &$writes): PDOStatement {
if (str_contains($sql, 'FROM `folder_map` AS `smap`')) {
return $direct;
}
if ($sql === 'SELECT `id`, `path` FROM `folder`;') {
return $tree;
}
if (str_contains($sql, 'CASE `id`')) {
// params are N (id, count) pairs, then N (id, skip) pairs, then N (id, time) pairs, then N ids
$n = intdiv(count($params), 7);
for ($i = 0; $i < $n; $i++) {
$folderId = (int) $params[$i * 2];
$count = (int) $params[$i * 2 + 1];
$skip = (int) $params[2 * $n + $i * 2 + 1];
$time = (int) $params[4 * $n + $i * 2 + 1];
$writes[$folderId] = [$count, $skip, $time];
}
}
return $this->createMock(PDOStatement::class);
});
// only the two leaf folders hold media
$direct->method('fetch')->willReturn(
['folder_id' => '4', 'total_count' => '2', 'total_skip' => '1', 'time' => '100'],
['folder_id' => '5', 'total_count' => '3', 'total_skip' => '0', 'time' => '150'],
false
);
// 1 -> 2 -> 4 and 1 -> 2 -> 5, plus an empty sibling
$tree->method('fetch')->willReturn(
['id' => '1', 'path' => ''],
['id' => '2', 'path' => '1'],
['id' => '3', 'path' => '1'],
['id' => '4', 'path' => '1,2'],
['id' => '5', 'path' => '1,2'],
false
);
$this->subject->update_folder_counts();
// Stats::count() increments every ancestor as a track plays, so the rebuild has to match that
self::assertSame([2, 1, 100], $writes[4]);
self::assertSame([3, 0, 150], $writes[5]);
self::assertSame([5, 1, 250], $writes[2]);
self::assertSame([5, 1, 250], $writes[1]);
self::assertArrayNotHasKey(3, $writes);
}
public function testUpdateUtimeUsesProvidedTime(): void
{
$this->connection->expects(static::once())
->method('query')
->with(
'UPDATE `folder` SET `update_time` = ? WHERE `id` = ?;',
[12345, 21],
);
$this->subject->update_utime(21, 12345);
}
protected function setUp(): void
{
$this->connection = $this->createMock(DatabaseConnectionInterface::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->subject = new FolderRepository($this->connection, $this->logger);
}
private function folderAt(int $id, string $pathName): Folder
{
$folder = new Folder();
$folder->id = $id;
$folder->path_name = $pathName;
return $folder;
}
}