<?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);
}
}