SeekquarrySeekquarry - Yioop Repo - Seekquarry

/ tests / UtilityTest.php⤓
<?php
/**
 * SeekQuarry/Yioop --
 * Open Source Pure PHP Search Engine, Crawler, and Indexer
 *
 * Copyright (C) 2009 - 2026  Chris Pollett chris@pollett.org
 *
 * LICENSE:
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU 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 General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 *
 * END LICENSE
 *
 * @author Chris Pollett chris@pollett.org
 * @license https://www.gnu.org/licenses/ GPL3
 * @link https://www.seekquarry.com/
 * @copyright 2009 - 2026
 * @filesource
 */
namespace seekquarry\yioop\tests;
/* runWithTimeLimit's tick function runs only in code compiled with this,
   so the slow work in the test below can be stopped. */
declare(ticks=1000);
use seekquarry\yioop\configs as C;
use seekquarry\yioop\library as L;
use seekquarry\yioop\library\UnitTest;

/**
 * UtilityTest checks the parts of Utility.php that stand on their own.
 * What is here so far is the counting of log messages that would
 * otherwise be written thousands of times a second. A reader of a broken
 * index meets the same fault on every posting, and writing an entry for
 * each of them made the log the slow part of the machine. What is
 * checked here is that many occurrences cost one entry, that the entry
 * says how many there were and over what range of values, and that what
 * a process still holds is written before it stops.
 *
 * @author Chris Pollett
 */
class UtilityTest extends UnitTest
{
    /**
     * COUNTER_LOG is the whole path, without its .log ending, of the log
     * the crawlLogCounter cases write, in the tests folder.
     */
    const COUNTER_LOG = C\TEST_DIR . "/test_files/counter_test";
    /**
     * $held is what the logging settings were before a case changed
     * them, so tearDown can put them back.
     * @var array
     */
    public $held = [];
    /**
     * $log is the file the cases read back what was written from.
     * @var string
     */
    public $log = "";
    /**
     * setUp keeps the logging settings a case is about to change, sends
     * what is written to a log of its own, and empties that log.
     */
    public function setUp()
    {
        $this->held = ["NO_LOGGING" => $_SERVER["NO_LOGGING"] ?? null,
            "LOG_TO_FILES" => $_SERVER["LOG_TO_FILES"] ?? null,
            "NO_ROTATE_LOGS" => $_SERVER["NO_ROTATE_LOGS"] ?? null];
        unset($_SERVER["NO_LOGGING"]);
        $_SERVER["LOG_TO_FILES"] = true;
        $_SERVER["NO_ROTATE_LOGS"] = true;
        $this->log = self::COUNTER_LOG . ".log";
        file_put_contents($this->log, "");
    }
    /**
     * tearDown puts the logging settings back and takes the log away.
     */
    public function tearDown()
    {
        @unlink($this->log);
        foreach ($this->held as $name => $value) {
            if ($value === null) {
                unset($_SERVER[$name]);
            } else {
                $_SERVER[$name] = $value;
            }
        }
    }
    /**
     * said gives back everything written to this case's log so far.
     *
     * @return string what the log holds
     */
    public function said()
    {
        return file_exists($this->log) ? file_get_contents($this->log) : "";
    }
    /**
     * seenSiteUnpacksAsPackedTestCase checks that a site packSeenSite
     * packs with its page apart unpacks to the same fields, with the page
     * as the gzip bytes the archive stores, and that a site a fetcher not
     * yet updated sends, compressed whole, unpacks as before. A site
     * without a page is compressed whole.
     */
    public function seenSiteUnpacksAsPackedTestCase()
    {
        $page = "<html><body>" . str_repeat("mary pollett ", 40) .
            "</body></html>";
        $site = [L\CrawlConstants::URL => "https://www.example.com/",
            L\CrawlConstants::TITLE => "Example",
            L\CrawlConstants::PAGE => $page];
        $packed = L\packSeenSite($site, 6, 1);
        $this->assertEqual($packed[0], L\CrawlConstants::PAGE_APART_MARK,
            "a site with a page is packed with the page apart");
        $unpacked = L\unpackSeenSite($packed);
        $sent_page = $unpacked[L\CrawlConstants::PAGE][
            L\CrawlConstants::PRECOMPRESSED_PAGE] ?? "";
        $this->assertEqual(unserialize(gzdecode($sent_page)), $page,
            "the page comes back as gzip bytes of the page");
        unset($unpacked[L\CrawlConstants::PAGE], $site[L\CrawlConstants::PAGE]);
        $this->assertEqual($unpacked, $site,
            "the rest of the site comes back as it was");
        $old_packed = gzcompress(serialize($site + [L\CrawlConstants::PAGE =>
            $page]), 1);
        $this->assertEqual(L\unpackSeenSite($old_packed)[
            L\CrawlConstants::PAGE] ?? "", $page,
            "a site compressed whole unpacks as before");
        $this->assertEqual(L\unpackSeenSite(L\packSeenSite($site, 6, 1)),
            $site, "a site without a page is compressed whole");
    }
    /**
     * precompressedPageGivesSameStringTestCase checks that a site whose
     * page the fetcher has already gzip encoded, with the bytes under
     * PRECOMPRESSED_PAGE, packs to the same string as the site with only
     * its page, so the queue server receives what it received before.
     */
    public function precompressedPageGivesSameStringTestCase()
    {
        $page = "<html><body>" . str_repeat("sled dogs of alaska ", 60) .
            "</body></html>";
        $site = [L\CrawlConstants::URL => "https://www.example.com/dogs",
            L\CrawlConstants::TITLE => "Dogs",
            L\CrawlConstants::PAGE => $page];
        $encoded_site = $site + [L\CrawlConstants::PRECOMPRESSED_PAGE =>
            gzencode(serialize($page), 6)];
        $this->assertEqual(L\packSeenSite($encoded_site, 6, 1),
            L\packSeenSite($site, 6, 1),
            "an already encoded page packs to the same string");
    }
    /**
     * manyOccurrencesCostOneEntryTestCase checks the whole point of
     * counting a message: a thousand occurrences inside the waiting time
     * write nothing at all.
     */
    public function manyOccurrencesCostOneEntryTestCase()
    {
        for ($i = 0; $i < 1000; $i++) {
            L\crawlLogCounter("a fault on every posting", self::COUNTER_LOG);
        }
        $this->assertEqual("", $this->said(),
            "a thousand occurrences inside the waiting time write nothing");
    }
    /**
     * waitOfNothingWritesASummaryTestCase checks the other end of the
     * waiting time: a message told to wait no seconds writes a summary
     * saying how many times it happened since the one before.
     */
    public function waitOfNothingWritesASummaryTestCase()
    {
        L\crawlLogCounter("a fault worth an entry", self::COUNTER_LOG, 0);
        L\crawlLogCounter("a fault worth an entry", self::COUNTER_LOG, 0);
        $lines = array_filter(explode("\n", trim($this->said())));
        $this->assertEqual(2, count($lines),
            "each occurrence writes one line and no more");
        $this->assertTrue(strpos($lines[1], "[1 times in 0 s]") !== false,
            "and a summary counts only what came after the one before");
    }
    /**
     * summaryReadsAsTheMessageDidTestCase checks that the message a
     * caller wrote is still what the log says, with the count and the
     * stretch added at the end.
     */
    public function summaryReadsAsTheMessageDidTestCase()
    {
        L\crawlLogCounter("Posting decode error", self::COUNTER_LOG, 0);
        $this->assertTrue(strpos($this->said(), "Posting decode error") !==
            false, "the message a caller wrote is what the log says");
    }
    /**
     * messagesAreCountedApartTestCase checks that two messages counted
     * at once do not run into each other, since a reader of a broken
     * index meets several faults and each has its own count.
     */
    public function messagesAreCountedApartTestCase()
    {
        L\crawlLogCounter("the first fault", self::COUNTER_LOG, 0);
        L\crawlLogCounter("the first fault", self::COUNTER_LOG, 0);
        L\crawlLogCounter("the second fault", self::COUNTER_LOG, 0);
        $said = $this->said();
        $this->assertEqual(2, substr_count($said, "the first fault ["),
            "the first message counts its own occurrences");
        $this->assertTrue(strpos($said, "the second fault [1 times") !==
            false, "and so does the second");
    }
    /**
     * nothingIsWrittenWhenLoggingIsOffTestCase checks that a counted
     * message obeys the same setting every other log message does.
     */
    public function nothingIsWrittenWhenLoggingIsOffTestCase()
    {
        $_SERVER["NO_LOGGING"] = true;
        L\crawlLogCounter("a fault nobody asked to hear about",
            self::COUNTER_LOG, 0);
        unset($_SERVER["NO_LOGGING"]);
        $this->assertEqual("", $this->said(),
            "a run that asked for no logging is given none");
    }
    /**
     * fileIsHandedToTheWebUserTestCase checks that a file written
     * before the server drops from root to the user it runs as is
     * given to that user. Without it the file belongs to root, and
     * every later run as that user finds a file it cannot write: the
     * tests run from the screen printed a warning for each attempt.
     */
    public function fileIsHandedToTheWebUserTestCase()
    {
        $file_name = C\TEST_DIR . "/test_files/handed_over_test.txt";
        file_put_contents($file_name, "a file");
        $this->assertTrue(!L\giveFileToWebUser(""),
            "a file that is not there is left alone");
        $done = L\giveFileToWebUser($file_name);
        $named = !empty(C\WWW_USER);
        $rooted = function_exists("posix_getuid") &&
            posix_getuid() === 0;
        if (!$named || !$rooted) {
            $this->assertTrue(!$done, "nothing is handed over where " .
                "no user is named or this process may not give it");
        } else {
            clearstatcache();
            $owner = posix_getpwuid(fileowner($file_name));
            $this->assertEqual(C\WWW_USER, $owner["name"],
                "the file belongs to the user the server runs as");
        }
        unlink($file_name);
    }
    /**
     * logMadeByRootIsWrittenAgainTestCase checks that a log file made
     * while running as root can be appended to by the user the site
     * runs as. Without the handing over the append fails and, being
     * unchecked, says nothing, so a program started from the machines
     * screen wrote no log at all while the same program run with sudo
     * wrote one.
     */
    public function logMadeByRootIsWrittenAgainTestCase()
    {
        $file_name = C\TEST_DIR . "/test_files/handover_log_test.log";
        file_put_contents($file_name, "made first\n");
        L\giveFileToWebUser($file_name);
        clearstatcache();
        $named = !empty(C\WWW_USER) && function_exists("posix_getuid") &&
            posix_getuid() === 0;
        if ($named) {
            $owner = posix_getpwuid(fileowner($file_name));
            $this->assertEqual(C\WWW_USER, $owner["name"],
                "the log belongs to the user the site runs as");
        }
        $added = file_put_contents($file_name, "and again\n",
            FILE_APPEND);
        $this->assertTrue($added > 0,
            "a later write to the log lands rather than failing");
        unlink($file_name);
    }
    /**
     * workPastItsLimitIsStoppedTestCase checks runWithTimeLimit. Work that
     * finishes inside its limit gives back true and its result. A loop in
     * a file that declares ticks, running past a limit of a twentieth of
     * a second, is stopped near that limit and gives back false, which is
     * how the indexer skips a document that takes too long.
     */
    public function workPastItsLimitIsStoppedTestCase()
    {
        $quick = L\runWithTimeLimit(0.05, fn() => 42);
        $this->assertEqual($quick, [true, 42], "work inside its limit " .
            "gives back its result");
        $start = microtime(true);
        $slow = L\runWithTimeLimit(0.05, function () {
            $count = 0;
            $end = microtime(true) + 5;
            while (microtime(true) < $end) {
                $count++;
            }
            return $count;
        });
        $took = microtime(true) - $start;
        $this->assertEqual($slow, [false, null], "work past its limit " .
            "is stopped");
        $this->assertTrue($took >= 0.05 && $took < 0.5,
            "work past its limit stops near " .
            "the limit");
    }
    /**
     * emptySizeIsZeroTestCase checks that metricToInt reads an empty
     * setting as 0 without a warning. A profile whose
     * GIT_MAX_PUSH_SIZE, a setting since removed, was saved empty made
     * metricToInt read the last
     * letter of an empty string. The warning it printed was output, so
     * index.php could no longer set session.gc_maxlifetime. Sizes with a
     * letter, and with spaces around them, still convert in powers of
     * 1000.
     */
    public function emptySizeIsZeroTestCase()
    {
        $warnings = [];
        set_error_handler(function ($number, $message) use (&$warnings) {
            $warnings[] = $message;
            return true;
        });
        $empty = L\metricToInt("");
        restore_error_handler();
        $this->assertEqual([$empty, $warnings], [0, []],
            "an empty size is 0 with no warning");
        $this->assertEqual([L\metricToInt("8M"), L\metricToInt(" 512K "),
            L\metricToInt("2G"), L\metricToInt("100")],
            [8000000, 512000, 2000000000, 100],
            "sizes with a letter convert in powers of 1000");
    }
    /**
     * metricCountsRoundToTheirLetterTestCase checks the short form
     * intToMetric gives the counts on badges: a number under 1000 as it
     * is, then thousands, millions and billions rounded to the nearest
     * one with K, M or G, and a count that rounds up to 1000 of one letter
     * written with the next letter, as 1M rather than 1000K.
     */
    public function metricCountsRoundToTheirLetterTestCase()
    {
        $this->assertEqual(array_map("seekquarry\\yioop\\library\\intToMetric",
            [0, 999, 1000, 1499, 1500, 999499, 999500, 2400000,
            999999999]), ["0", "999", "1K", "1K", "2K", "999K", "1M", "2M",
            "1G"], "each count with its letter");
        $this->assertEqual(L\intToMetric("12"), "12",
            "a value that is not a whole number is kept");
    }
    /**
     * unlinkRecursiveTakesLinksTestCase checks that deleting a folder
     * removes a symbolic link in it as a link. A link whose file is gone
     * was left behind before, since deleteFileOrDir asked file_exists,
     * which follows the link, so the folder stayed. A link to a folder
     * outside was followed, and that folder's files were deleted. Where
     * the system makes no symbolic links the case has nothing to check.
     */
    public function unlinkRecursiveTakesLinksTestCase()
    {
        $folder = C\TEST_DIR . "/test_files/unlink_links_test";
        $outside = C\TEST_DIR . "/test_files/unlink_links_outside";
        $manager = new \seekquarry\yioop\models\datasources\Sqlite3Manager();
        $manager->unlinkRecursive($folder);
        $manager->unlinkRecursive($outside);
        L\makePath($folder);
        L\makePath($outside);
        file_put_contents("$outside/kept.txt", "kept");
        if (!@symlink("$folder/gone.css", "$folder/dangling.css") ||
            !@symlink($outside, "$folder/elsewhere")) {
            $manager->unlinkRecursive($folder);
            $manager->unlinkRecursive($outside);
            return;
        }
        $manager->unlinkRecursive($folder);
        $this->assertFalse(file_exists($folder) || is_link($folder),
            "the folder with its links is gone");
        $this->assertTrue(file_exists("$outside/kept.txt"),
            "a folder a link points to keeps its files");
        $manager->unlinkRecursive($folder);
        $manager->unlinkRecursive($outside);
    }
}