SeekquarrySeekquarry - Yioop Repo - Seekquarry

/ tests / ResourceFoldersTest.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
 * @package seek_quarry\tests
 */
namespace seekquarry\yioop\tests;

use seekquarry\yioop\configs as C;
use seekquarry\yioop\library as L;
use seekquarry\yioop\library\ResourceFolders;
use seekquarry\yioop\library\UnitTest;
use seekquarry\yioop\models\Model;

/* the upgrade functions are loaded only when a database is upgraded */
require_once C\BASE_DIR . "/library/VersionFunctions.php";

/**
 * ResourceFoldersTest checks the folder names ResourceFolders makes:
 * fixed names, the paths of pages, posts, users and hosts, links made
 * relative, and the sub-folder of a file in a page folder.
 *
 * @author Chris Pollett
 */
class ResourceFoldersTest extends UnitTest
{
    /**
     * The stand-in for app/resources the paths are made under.
     */
    const RESOURCES = "/sites/one/app/resources";
    /**
     * setUp runs before each case; nothing is needed here.
     */
    public function setUp()
    {
    }
    /**
     * tearDown runs after each case; nothing is needed here.
     */
    public function tearDown()
    {
    }
    /**
     * fixedNameKeepsSevenLettersAndAHashTestCase checks a fixed name is
     * the first seven characters of the urlencoded name with anything
     * but letters and digits made _, padded to eight with _, then the
     * sixteen hexadecimal digits of the name's raw crawlHash; and that
     * two names differing only in letter case get names that still
     * differ when letter case is ignored, as on macOS and Windows.
     */
    public function fixedNameKeepsSevenLettersAndAHashTestCase()
    {
        $hash = bin2hex(L\crawlHash("Main", true));
        $this->assertEqual(ResourceFolders::fixedName("Main"),
            "Main____" . $hash, "a short name is padded with _");
        $this->assertEqual(substr(ResourceFolders::fixedName(
            "Ünïcode page-name.v2"), 0, 8), "_C3_9Cn_",
            "characters that are not letters or digits become _");
        $this->assertEqual(preg_match('/^[A-Za-z0-9_]{8}[0-9a-f]{16}$/',
            ResourceFolders::fixedName(".hidden/../x")), 1,
            "a fixed name is 24 letters, digits and _, never a dot");
        $this->assertTrue(strtolower(ResourceFolders::fixedName("main")) !==
            strtolower(ResourceFolders::fixedName("Main")),
            "names differing in case differ when case is ignored");
    }
    /**
     * pathsComeFromNamesTestCase checks the folders of a wiki page, a
     * post, a user, the feed pictures, the site and a host are laid out as
     * ResourceFolders says, from names alone.
     */
    public function pathsComeFromNamesTestCase()
    {
        $group = self::RESOURCES . "/" . ResourceFolders::fixedName("Fun");
        $this->assertEqual(ResourceFolders::wikiPagePath("Fun", "en-US",
            "Main", self::RESOURCES), "$group/wiki/en-US/" .
            ResourceFolders::fixedName("Main"), "a wiki page's folder");
        $this->assertEqual(ResourceFolders::postPath("Fun", "Bob",
            1700000000, self::RESOURCES), "$group/posts/2023-11/" .
            ResourceFolders::fixedName("bob@1700000000"),
            "a post's folder, from its author lowercased and its time");
        $this->assertEqual(ResourceFolders::userPath("Bob",
            self::RESOURCES), self::RESOURCES . "/users/" .
            ResourceFolders::fixedName("bob"), "a user's folder");
        $this->assertEqual(ResourceFolders::sitePath(self::RESOURCES),
            self::RESOURCES . "/site", "the site's folder");
        $this->assertEqual([ResourceFolders::domainPath("WWW.Example.com",
            self::RESOURCES), ResourceFolders::domainPath("../../etc",
            self::RESOURCES), ResourceFolders::domainPath("/",
            self::RESOURCES)], [self::RESOURCES . "/domains/www.example.com",
            self::RESOURCES . "/domains/etc", ""],
            "a host's folder never leaves the domains folder");
    }
    /**
     * linksAreRelativeTestCase checks relativeLinkTarget gives the path
     * from a link's folder to its target, and subPathInPageFolder the
     * sub-folder of a file in a page folder.
     */
    public function linksAreRelativeTestCase()
    {
        $this->assertEqual(ResourceFolders::relativeLinkTarget(
            "/r/a/b/link", "/r/c/page"), "../../c/page",
            "a link names its target relative to its own folder");
        $page = ResourceFolders::wikiPagePath("Fun", "en-US", "Main",
            self::RESOURCES) . "/page";
        $this->assertEqual([ResourceFolders::subPathInPageFolder(
            "$page/photos/2026/a.png"), ResourceFolders::subPathInPageFolder(
            "$page/a.png")], ["photos/2026", ""],
            "the sub-folder of a file in a page folder");
    }
    /**
     * siteImageUrlFindsTheRoleImageTestCase checks how a page finds the
     * site's image for an appearance role, with nothing in Profile.php:
     * with no image uploaded for LOGO_SMALL it gets the logo Yioop ships;
     * once small-logo.webp is in the site folder it gets that file, with
     * the time it was saved at the end of its address; and a background,
     * which Yioop ships none of, has no address until one is uploaded.
     */
    public function siteImageUrlFindsTheRoleImageTestCase()
    {
        $resources = C\TEST_DIR . "/test_files/site_image_test";
        $model = new Model();
        $model->db->unlinkRecursive($resources);
        $this->assertEqual(ResourceFolders::siteImageUrl("LOGO_SMALL",
            $resources), "resources/small-logo.webp",
            "with no upload the shipped logo is used");
        $this->assertEqual(ResourceFolders::siteImageUrl(
            "BACKGROUND_IMAGE", $resources), "",
            "with no upload there is no background");
        L\makePath(ResourceFolders::sitePath($resources));
        $file = ResourceFolders::sitePath($resources) . "/small-logo.webp";
        file_put_contents($file, "an image");
        $url = ResourceFolders::siteImageUrl("LOGO_SMALL", $resources);
        $this->assertTrue(str_contains($url, "small-logo.webp") &&
            str_ends_with($url, "v=" . filemtime($file)),
            "the uploaded logo is used, its address ending in its time");
        $model->db->unlinkRecursive($resources);
    }
    /**
     * oldSiteImagesTakeTheirRoleNamesTestCase checks the version 135
     * upgrade's renaming of images uploaded under their own names. The
     * large logo's address names its file by path, the small logo's by
     * n=, and both are renamed to their roles' names; the favicon's
     * address names the shipped favicon.ico, which is not in the site
     * folder, and nothing is done for it.
     */
    public function oldSiteImagesTakeTheirRoleNamesTestCase()
    {
        $resources = C\TEST_DIR . "/test_files/site_image_test";
        $model = new Model();
        $model->db->unlinkRecursive($resources);
        $site_path = ResourceFolders::sitePath($resources);
        L\makePath($site_path);
        file_put_contents("$site_path/Seekquarry.png", "large");
        file_put_contents("$site_path/small.gif", "small");
        $moved = L\moveSiteImagesToRoleNames([
            "LOGO_LARGE" => "wd/resources/Seekquarry.png",
            "LOGO_SMALL" => "?c=resource&a=get&f=resources&" .
                "n=small.gif",
            "FAVICON" => "favicon.ico"], $resources);
        $this->assertEqual($moved, 2, "two images are renamed");
        $this->assertEqual(@file_get_contents("$site_path/logo-large.png"),
            "large", "the large logo has its role's name");
        $this->assertEqual(@file_get_contents("$site_path/logo-small.gif"),
            "small", "the small logo has its role's name");
        $this->assertTrue(!file_exists("$site_path/favicon.ico"),
            "the shipped favicon is not made a site image");
        $model->db->unlinkRecursive($resources);
    }
    /**
     * siteImageDataMakesWebpAndIconFilesTestCase draws a 300 by 20 PNG
     * with a transparent half and checks what the site keeps of it: as a
     * logo, a WebP of the same size whose transparent pixels stay
     * transparent; as a favicon, an icon file holding a PNG scaled to fit
     * 256 pixels. An icon file uploaded as a favicon is kept as it was.
     */
    public function siteImageDataMakesWebpAndIconFilesTestCase()
    {
        $image = imagecreatetruecolor(300, 20);
        imagealphablending($image, false);
        imagesavealpha($image, true);
        imagefilledrectangle($image, 0, 0, 149, 19,
            imagecolorallocatealpha($image, 0, 0, 255, 0));
        imagefilledrectangle($image, 150, 0, 299, 19,
            imagecolorallocatealpha($image, 0, 0, 0, 127));
        ob_start();
        imagepng($image);
        $png = ob_get_clean();
        $webp = ResourceFolders::siteImageData($png, "LOGO_LARGE");
        $this->assertEqual(substr($webp, 8, 4), "WEBP", "a logo is a WebP");
        $drawn = imagecreatefromstring($webp);
        $this->assertTrue(imagesx($drawn) == 300 &&
            imagecolorsforindex($drawn, imagecolorat($drawn, 200, 10))[
            'alpha'] == 127, "the logo keeps its size and transparency");
        $icon = ResourceFolders::siteImageData($png, "FAVICON");
        $header = unpack("vreserved/vtype/vcount/Cwidth/Cheight",
            substr($icon, 0, 8));
        $this->assertTrue($header['type'] == 1 && $header['count'] == 1 &&
            $header['width'] == 0 && $header['height'] == 17 &&
            substr($icon, 22, 4) == "\x89PNG",
            "a favicon is an icon file holding a PNG at most 256 wide");
        $this->assertEqual(ResourceFolders::siteImageData($icon, "FAVICON"),
            $icon, "an icon file uploaded as a favicon is kept as it was");
    }
    /**
     * oldRoleImagesBecomeWebpTestCase checks the version 136 upgrade. A
     * site whose version 135 files are logo-large.png and favicon.ico ends
     * with large-logo.webp, a WebP, in place of the PNG, and its favicon.ico
     * as it was.
     */
    public function oldRoleImagesBecomeWebpTestCase()
    {
        $resources = C\TEST_DIR . "/test_files/site_image_test";
        $model = new Model();
        $model->db->unlinkRecursive($resources);
        $site_path = ResourceFolders::sitePath($resources);
        L\makePath($site_path);
        $image = imagecreatetruecolor(4, 4);
        ob_start();
        imagepng($image);
        file_put_contents("$site_path/logo-large.png", ob_get_clean());
        file_put_contents("$site_path/favicon.ico", "an icon");
        $this->assertEqual(L\convertSiteImagesToWebp($resources), 1,
            "one image is moved");
        $this->assertTrue(!file_exists("$site_path/logo-large.png") &&
            substr((string)@file_get_contents("$site_path/large-logo.webp"),
            8, 4) == "WEBP", "the large logo is now large-logo.webp");
        $this->assertEqual(file_get_contents("$site_path/favicon.ico"),
            "an icon", "the favicon is left as it was");
        $model->db->unlinkRecursive($resources);
    }
    /**
     * moveThemesToCssFolder brings the themes of versions 122 to 135 up
     * from app/css/default into app/css. A theme named like search.css,
     * which is not a theme, comes up under another name; one whose name
     * and rules a file there already has is dropped; the emptied folder
     * is removed.
     */
    public function themesMoveUpToTheCssFolderTestCase()
    {
        $css = C\TEST_DIR . "/test_files/theme_move_test";
        $model = new Model();
        $model->db->unlinkRecursive($css);
        L\makePath("$css/default");
        file_put_contents("$css/search.css", "/* search */");
        file_put_contents("$css/same.css", "/* same */");
        file_put_contents("$css/default/plain.css", "/* plain */");
        file_put_contents("$css/default/search.css", "/* a theme */");
        file_put_contents("$css/default/same.css", "/* same */");
        $this->assertEqual(L\moveThemesToCssFolder($css),
            ["search" => "search-theme"], "only the search theme is renamed");
        $this->assertEqual(file_get_contents("$css/plain.css"),
            "/* plain */", "a theme is moved up as it was");
        $this->assertEqual(file_get_contents("$css/search-theme.css"),
            "/* a theme */", "the search theme comes up as search-theme");
        $this->assertEqual(file_get_contents("$css/search.css"),
            "/* search */", "search.css is left as it was");
        $this->assertFalse(file_exists("$css/default"),
            "the old themes folder is removed");
        $model->db->unlinkRecursive($css);
    }
    /**
     * linkUsedThemes136 links the default domain to the theme the profile
     * named and each routed domain to the theme its appearance row named,
     * by a renamed theme's new name, and takes AUXILIARY_CSS_NAME out of
     * each row.
     */
    public function usedThemesBecomeDomainLinksTestCase()
    {
        $folder = C\TEST_DIR . "/test_files/used_themes_test";
        (new Model())->db->unlinkRecursive($folder);
        L\makePath("$folder/css");
        $profile_model = new class("$folder/css") extends
            \seekquarry\yioop\models\ProfileModel {
            /** $themes is the folder this model keeps its themes in */
            public $themes;
            /**
             * __construct keeps $themes and makes no database connection.
             *
             * @param string $themes the folder to keep the themes in
             */
            public function __construct($themes)
            {
                $this->themes = $themes;
            }
            /**
             * themeFolder gives $themes in place of the site's app/css.
             *
             * @return string the folder's path
             */
            public function themeFolder()
            {
                return $this->themes;
            }
        };
        $profile_model->resource_path = "$folder/resources";
        $profile_model->saveThemeStylesheet("used-site",
            "body { color: red; }");
        $profile_model->saveThemeStylesheet("used-domain",
            "body { color: blue; }");
        $db = new class {
            /** rows of the stand-in DOMAIN_APPEARANCE table */
            public $rows = [];
            /** statements run with their parameters */
            public $run = [];
            /**
             * Gives every row for a SELECT and records anything else.
             *
             * @param string $sql statement to run
             * @param array $params values for its question marks
             * @return mixed rows still to fetch, or true
             */
            public function execute($sql, $params = [])
            {
                $this->run[] = [$sql, $params];
                return (str_starts_with($sql, "SELECT")) ?
                    new \ArrayIterator($this->rows) : true;
            }
            /**
             * Gives the next row a SELECT found.
             *
             * @param \ArrayIterator $result what execute gave
             * @return mixed the row, or false when there are no more
             */
            public function fetchArray($result)
            {
                if (!$result->valid()) {
                    return false;
                }
                $row = $result->current();
                $result->next();
                return $row;
            }
        };
        $host = "used-link.example";
        $db->rows = [['DOMAIN' => $host, 'APPEARANCE' => serialize(
            ['SITE_NAME' => "Used Example",
            'AUXILIARY_CSS_NAME' => "old-name"])]];
        L\linkUsedThemes136($db, $profile_model, "used-site",
            ["old-name" => "used-domain"]);
        $this->assertEqual($profile_model->domainTheme(""), "used-site",
            "the default domain wears the profile's theme");
        $this->assertEqual($profile_model->domainTheme($host), "used-domain",
            "a routed domain wears its row's theme by its new name");
        $update = $db->run[1] ?? ["", []];
        $this->assertEqual(unserialize($update[1][0] ?? ""),
            ['SITE_NAME' => "Used Example"],
            "the row keeps its other fields and loses AUXILIARY_CSS_NAME");
        (new Model())->db->unlinkRecursive($folder);
    }
}