Ctrl K
ComunidadGuías…

tests/Module/Statistics/StatsTest.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\Module\Statistics;

use Ampache\Config\AmpConfig;
use Ampache\Module\Authorization\Check\PrivilegeCheckerInterface;
use Ampache\Repository\CatalogRepositoryInterface;
use PHPUnit\Framework\TestCase;
use Psr\Container\ContainerInterface;
use Psr\Log\LoggerInterface;
use ReflectionMethod;

/**
 * Covers the play-date clamp.
 *
 * Every play date is client-supplied: the `date` parameter on record_play and scrobble, and the `time` a Subsonic
 * client sends. A date in the future never ages out, so one wrong clock pins itself to the top of every recently
 * played list, feed and api until real time catches up. Three writers reach a date column and all three used to
 * take the value as-is, which is why the clamp is pinned here rather than at each call site.
 */
class StatsTest extends TestCase
{
    public function testClockDriftIsKept(): void
    {
        // rewriting the date of a device that is merely off ntp would make the exact-match duplicate guard miss
        $date = time() + 30;

        self::assertSame($date, $this->clamp($date));
    }

    public function testDateBeforeTheEpochBecomesNow(): void
    {
        $this->assertIsNow(-1);
        $this->assertIsNow(-2000000000);
    }

    public function testFutureDateBecomesNow(): void
    {
        $this->assertIsNow(time() + 3600);
        $this->assertIsNow(time() + 31536000);
        $this->assertIsNow(4294967295);
    }

    public function testMissingDateBecomesNow(): void
    {
        $this->assertIsNow(null);
        $this->assertIsNow(0);
    }

    public function testNowIsKept(): void
    {
        $now = time();

        self::assertSame($now, $this->clamp($now));
    }

    public function testPastDateIsKept(): void
    {
        // an offline client catching up on its queue is legitimate, only the future direction is not
        self::assertSame(1, $this->clamp(1));
        self::assertSame(1500000000, $this->clamp(1500000000));
    }

    public function testTheCachedTopListCarriesTheConditionToo(): void
    {
        $this->bootDic(false);
        AmpConfig::set('cron_cache', true, true);

        try {
            self::assertStringContainsString('`id` = `cache_object_count`.`object_id`', Stats::get_top_sql('song'));
        } finally {
            AmpConfig::set('cron_cache', false, true);
        }
    }

    /**
     * The cron cache is read by the widgets whenever it is warm, so leaving it unfiltered would serve
     * withdrawn releases on exactly the instances that turned the cache on. `cache_object_count` keys a
     * disk row on the disk id, so the flag is two hops away: reading `album` against that id compares a
     * disk id to an album id and keeps whatever happens to collide
     */
    public function testTheCachedTopListOfDisksResolvesTheDiskToItsAlbum(): void
    {
        $this->bootDic(false);
        AmpConfig::set('cron_cache', true, true);

        try {
            $sql = Stats::get_top_sql('album_disk');

            self::assertStringContainsString('`disk_wd`.`id` = `cache_object_count`.`object_id`', $sql);
            self::assertStringContainsString('`album_wd`.`id` = `disk_wd`.`album_id`', $sql);
        } finally {
            AmpConfig::set('cron_cache', false, true);
        }
    }

    /**
     * The cache is filled through this very statement and then read by everybody, managers included, so a run
     * that filtered would freeze a truncated top list for the whole instance until the next sweep.
     */
    public function testTheCacheIsWrittenWithoutTheCondition(): void
    {
        $this->bootDic(false);

        try {
            $sql = Stats::get_top_sql('song', 0, 'stream', null, false, 0, 0, true);

            self::assertStringNotContainsString('`song`.`enabled`', $sql);
        } finally {
            AmpConfig::set('cron_cache', false, true);
        }
    }

    public function testTheRecentWidgetDropsWithdrawnItemsForAListener(): void
    {
        $this->bootDic(false);

        // this path reads `album` itself, so the flag is stated directly rather than through a subquery
        self::assertStringContainsString('`album`.`enabled` = 1', Stats::get_recent_sql('album'));
        self::assertStringNotContainsString('`album`.`id` = `album`.`id`', Stats::get_recent_sql('album'));
    }

    public function testTheRecentWidgetKeepsWithdrawnItemsForAManager(): void
    {
        $this->bootDic(true);

        self::assertStringNotContainsString('`album`.`enabled` = 1', Stats::get_recent_sql('album'));
    }

    /**
     * The recent widget reads the tables holding `last_played` rather than the play history, and a disk has
     * no flag of its own there either
     */
    public function testTheRecentWidgetOfDisksReadsTheAlbum(): void
    {
        $this->bootDic(false);

        self::assertStringContainsString(
            '`id` = `album_disk`.`album_id`',
            Stats::get_recent_sql('album_disk')
        );
    }

    public function testTheTopListDropsWithdrawnItemsForAListener(): void
    {
        $this->bootDic(false);

        self::assertStringContainsString('`enabled` = 1', Stats::get_top_sql('song'));
    }

    public function testTheTopListKeepsWithdrawnItemsForAManager(): void
    {
        $this->bootDic(true);

        self::assertStringNotContainsString('`enabled` = 1', Stats::get_top_sql('song'));
    }

    /**
     * `$type` has become 'song' by the time the condition is built, so the column it correlates on is the
     * tell: a disk reads the state of its album, never the state of one of its tracks
     */
    public function testTheTopListOfDisksReadsTheAlbumAndNotTheSong(): void
    {
        $this->bootDic(false);

        $sql = Stats::get_top_sql('album_disk');

        self::assertStringContainsString('`id` = `album_disk`.`album_id`', $sql);
        self::assertStringNotContainsString('`song`.`enabled`', $sql);
    }

    private function assertIsNow(?int $date): void
    {
        $before = time();
        $result = $this->clamp($date);
        $after  = time();

        self::assertGreaterThanOrEqual($before, $result);
        self::assertLessThanOrEqual($after, $result);
    }

    private function bootDic(bool $isManager): void
    {
        $privilegeChecker = $this->createMock(PrivilegeCheckerInterface::class);
        $privilegeChecker->method('check')->willReturn($isManager);

        $catalogRepository = $this->createMock(CatalogRepositoryInterface::class);
        $catalogRepository->method('getIds')->willReturn([]);

        $dic = $this->createMock(ContainerInterface::class);
        $dic->method('get')->willReturnCallback(fn(string $id): object => match ($id) {
            CatalogRepositoryInterface::class => $catalogRepository,
            PrivilegeCheckerInterface::class => $privilegeChecker,
            default => $this->createMock(LoggerInterface::class),
        });

        $GLOBALS['dic'] = $dic;
    }

    private function clamp(?int $date): int
    {
        /** @var int $result */
        $result = new ReflectionMethod(Stats::class, '_clampDate')->invoke(null, $date);

        return $result;
    }
}