<?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\V10ResourceFolderMigration;
use seekquarry\yioop\library\ResourceFolders;
use seekquarry\yioop\library\UnitTest;
use seekquarry\yioop\models\FeedModel;
use seekquarry\yioop\models\Model;
use seekquarry\yioop\models\ProfileModel;
use seekquarry\yioop\models\datasources\Sqlite3Manager;
/**
* V10ResourceFolderMigrationTest stands up a site's database and an
* app/resources in the pre-version 10 layout, with folders named by
* hashes of ids and an AUTH_KEY, runs the version 10 migration, and
* checks where everything went and that a second run does nothing.
*
* @author Chris Pollett
*/
class V10ResourceFolderMigrationTest extends UnitTest
{
/**
* Folder under the test files the throwaway database and resources
* folder are made in.
*/
const TEST_DIR = '/test_files/resource_folder_migration';
/**
* PRE_V10_KEY is the AUTH_KEY the pre-version 10 folder names are made
* with.
*/
const PRE_V10_KEY = "a site key";
/**
* The time of the message user writer sent.
*/
const MESSAGE_TIME = 1700000000;
/**
* A throwaway Sqlite3 database built from ProfileModel definitions.
* @var object
*/
public $db;
/**
* Folder the database and resources folder are made in.
* @var string
*/
public $where;
/**
* The stand-in for app/resources.
* @var string
*/
public $resources;
/**
* Builds the throwaway database: users writer (5) and reader (6);
* Group 100, owned by writer, with page Page 1 in en-US; writer's
* personal group Personal$5; a group Personal$6 that writer made
* before reader had a personal group; a group named reader$Personal
* that writer made; a message in Personal$5 and its copy in
* Personal$6; a recommendation of a file of Page 1; and a host with
* its own appearance.
*/
public function setUp()
{
$this->where = __DIR__ . self::TEST_DIR;
if (!file_exists($this->where)) {
mkdir($this->where, 0777, true);
}
$this->resources = $this->where . "/resources" . uniqid();
mkdir($this->resources, 0777, true);
$this->db = new Sqlite3Manager();
$this->db->connect("", "", "", $this->where . "/migration" .
uniqid() . ".db");
$profile = new ProfileModel(C\DB_NAME, false);
$profile->initializeSql($this->db, ["DBMS" => "Sqlite3",
"DB_HOST" => ""]);
foreach (['USERS', 'SOCIAL_GROUPS', 'GROUP_PAGE', 'GROUP_ITEM',
'GROUP_RESOURCE_RECOMMENDATION', 'DOMAIN_APPEARANCE'] as $table) {
$this->db->execute($profile->create_statements[$table]);
}
foreach ([[5, "Writer"], [6, "reader"]] as $user) {
$this->db->execute("INSERT INTO USERS (USER_ID, USER_NAME) " .
"VALUES (?, ?)", $user);
}
foreach ([[100, "Group 100"], [60, "Personal\$5"],
[61, "Personal\$6"], [62, "reader\$Personal"]] as $group) {
$this->db->execute("INSERT INTO SOCIAL_GROUPS (GROUP_ID, " .
"GROUP_NAME, OWNER_ID) VALUES (?, ?, 5)", $group);
}
$this->db->execute("INSERT INTO GROUP_PAGE (ID, GROUP_ID, TITLE, " .
"LOCALE_TAG) VALUES (1, 100, 'Page 1', 'en-US')");
foreach ([[501, 60], [502, 61]] as $item) {
$this->db->execute("INSERT INTO GROUP_ITEM (ID, PARENT_ID, " .
"GROUP_ID, USER_ID, TITLE, DESCRIPTION, PUBDATE) VALUES " .
"(?, ?, ?, 5, 'hi', 'hi', ?)", [$item[0], $item[0],
$item[1], self::MESSAGE_TIME]);
}
$this->db->execute("INSERT INTO GROUP_RESOURCE_RECOMMENDATION " .
"(USER_ID, GROUP_ID, PAGE_ID, RESOURCE_PATH, SCORE) VALUES " .
"(6, 100, 1, ?, 1)", [$this->preV10Path("group1001") . "/a.txt"]);
$this->db->execute("INSERT INTO DOMAIN_APPEARANCE (DOMAIN, " .
"APPEARANCE) VALUES ('www.example.com', '')");
}
/**
* Takes down the throwaway database and folders.
*/
public function tearDown()
{
if ($this->db) {
$this->db->disconnect();
}
if ($this->where && file_exists($this->where)) {
$model = new Model();
$model->db->unlinkRecursive($this->where);
}
}
/**
* preV10Path gives the path the pre-version 10 layout gave the folder
* of a hash of $ids and PRE_V10_KEY, with t before the name for a
* thumbnail folder.
*
* @param string $ids what was hashed with the key, such as group1001
* @param string $before "t" for a thumbnail folder, else ""
* @return string the pre-version 10 folder's path
*/
private function preV10Path($ids, $before = "")
{
$hash = L\crawlHash($ids . self::PRE_V10_KEY);
return $this->resources . "/" . substr($hash, 0, 3) .
"/$before$hash";
}
/**
* makePreV10Folder makes a pre-version 10 folder holding one file.
*
* @param string $path the folder
* @param string $file_name the file to put in it
*/
private function makePreV10Folder($path, $file_name)
{
mkdir($path, 0777, true);
file_put_contents("$path/$file_name", $file_name);
}
/**
* preV10LayoutMovesToNamedFoldersTestCase lays out the pre-version 10
* folders of
* Page 1 and its thumbnails, writer's message and its copy, a link as
* linkResourceFolders made it, writer's icon, the feed pictures, the
* pre-version 10 thumbnails of the path Page 1's redirect.txt names, a
* folder no row names, a site logo and the host's folder, runs the
* migration and checks: personal groups are renamed by user name, the
* group writer made before reader had one becomes an ordinary group,
* as does the group named reader$Personal; each folder is in its new
* place, Page 1 keeps its own thumbnails and those of its redirect are
* set aside, the copy's link points there by a relative path, the folder
* nothing names is under unmigrated, the logo is in the site folder,
* the host's folder in domains, and the stored recommendation path is
* rewritten. A second run finds nothing to do.
*/
public function preV10LayoutMovesToNamedFoldersTestCase()
{
$this->makePreV10Folder($this->preV10Path("group1001"), "a.txt");
$this->makePreV10Folder($this->preV10Path("group1001", "t"),
"a.txt.webp");
/* Page 1 names a shared folder in its redirect.txt; the
pre-version 10 thumbnails of that path must not take the place
of Page 1's own */
file_put_contents($this->preV10Path("group1001") . "/redirect.txt",
"/elsewhere/shared");
$shared_hash = L\crawlHash("/elsewhere/shared");
$this->makePreV10Folder($this->resources . "/" .
substr($shared_hash, 0, 3) . "/t$shared_hash", "shared.webp");
$this->makePreV10Folder($this->preV10Path("group60post501"), "m.txt");
$copy = $this->preV10Path("group61post502");
mkdir(dirname($copy), 0777, true);
symlink($this->preV10Path("group60post501"), $copy);
$this->makePreV10Folder($this->preV10Path("user5"), "user_icon.webp");
$this->makePreV10Folder($this->preV10Path("group" . C\PUBLIC_GROUP_ID,
"t") . "/2026-10-01", "p.jpg");
$this->makePreV10Folder($this->preV10Path("group999"), "lost.txt");
file_put_contents($this->resources . "/logo.png", "logo");
file_put_contents($this->resources . "/recommendation.txt",
$this->preV10Path("group1001") . "\n");
$this->makePreV10Folder($this->resources . "/www.example.com",
"logo.png");
$migration = new V10ResourceFolderMigration($this->db,
$this->resources, self::PRE_V10_KEY);
$migration->run();
$names = [];
$result = $this->db->execute("SELECT GROUP_ID, GROUP_NAME FROM " .
"SOCIAL_GROUPS ORDER BY GROUP_ID");
while ($row = $this->db->fetchArray($result)) {
$names[$row['GROUP_ID']] = $row['GROUP_NAME'];
}
$this->assertEqual($names, [60 => "writer\$Personal",
61 => "Personal_6", 62 => "reader_Personal",
100 => "Group 100"], "personal groups are named by user name " .
"and groups that took such names become ordinary");
$page = ResourceFolders::wikiPagePath("Group 100", "en-US",
"Page 1", $this->resources);
$message = ResourceFolders::postPath("writer\$Personal", "writer",
self::MESSAGE_TIME, $this->resources);
$copy_page = ResourceFolders::postPath("Personal_6", "writer",
self::MESSAGE_TIME, $this->resources) . "/page";
$this->assertEqual([is_file("$page/page/a.txt"),
is_file("$page/thumbs/a.txt.webp"),
is_file("$message/page/m.txt"),
is_link($copy_page) && is_file("$copy_page/m.txt"),
str_starts_with((string)readlink($copy_page), "../"),
is_file(ResourceFolders::userPath("writer", $this->resources) .
"/user_icon.webp"), is_file(ResourceFolders::feedsPath(
$this->resources) . "/2026-10-01/p.jpg")],
array_fill(0, 7, true), "pages, thumbnails, messages, a " .
"relative link, an icon and the feed pictures are moved");
$this->assertEqual([is_file("$page/page/redirect.txt"),
is_file("$page/thumbs/shared.webp"), is_file($this->resources .
"/unmigrated/" . substr($shared_hash, 0, 3) .
"/t$shared_hash/shared.webp")], [true, false, true],
"a page keeps its own thumbnails over those of its redirect");
$this->assertEqual([is_file($this->resources . "/unmigrated/" .
basename(dirname($this->preV10Path("group999"))) . "/" .
basename($this->preV10Path("group999")) . "/lost.txt"),
is_file($this->resources . "/site/logo.png"),
is_file($this->resources . "/domains/www.example.com/logo.png"),
is_dir(dirname($this->preV10Path("group1001")))],
[true, true, true, false], "a folder nothing names is kept " .
"aside, site and host files move, and pre-version 10 folders " .
"are gone");
$result = $this->db->execute("SELECT RESOURCE_PATH FROM " .
"GROUP_RESOURCE_RECOMMENDATION");
$row = $this->db->fetchArray($result);
$this->assertEqual([$row['RESOURCE_PATH'], trim(file_get_contents(
$this->resources . "/recommendation.txt"))],
["$page/page/a.txt", "$page/page"],
"stored paths name the new folders");
$again = (new V10ResourceFolderMigration($this->db, $this->resources,
self::PRE_V10_KEY))->run();
unset($again["seconds"]);
$this->assertEqual(array_sum($again), 0,
"a second run finds nothing to do");
unlink($copy_page);
}
/**
* prefixFolderHoldingOnlyFinderFilesGoesTestCase checks the three-letter
* prefix folders the migration leaves. One holds only the .DS_Store
* macOS's Finder writes, and one only a Thumbs.db, as Windows Explorer
* writes; both are deleted, though no hashed folder was ever in them.
* One holds a file whose name is no pre-version 10 hash; it is kept,
* and the report names it and that file.
*/
public function prefixFolderHoldingOnlyFinderFilesGoesTestCase()
{
mkdir($this->resources . "/UuB");
file_put_contents($this->resources . "/UuB/.DS_Store", "finder");
mkdir($this->resources . "/Wq7");
file_put_contents($this->resources . "/Wq7/Thumbs.db", "explorer");
mkdir($this->resources . "/Abc");
file_put_contents($this->resources . "/Abc/notes.txt", "notes");
$lines = [];
$counts = (new V10ResourceFolderMigration($this->db,
$this->resources, self::PRE_V10_KEY,
function ($line) use (&$lines) {
$lines[] = $line;
}))->run();
$kept_line = $this->resources . "/Abc, which holds notes.txt";
$this->assertEqual([is_dir($this->resources . "/UuB"),
is_dir($this->resources . "/Wq7"),
is_file($this->resources . "/Abc/notes.txt"),
$counts["prefix folders kept"] ?? 0,
in_array("kept " . $kept_line, $lines)],
[false, false, true, 1, true], "prefix folders holding only " .
"Finder or Explorer files go, and one kept is reported");
}
/**
* oldPersonalPrefixIsOnlyInTheMigrationTestCase checks that the
* pre-version 10 personal group prefix is no longer a setting in
* Config.php. The migration that renames old personal groups keeps it
* as OLD_PERSONAL_GROUP_PREFIX, and the clipboard thread keeps the
* title its existing threads already carry.
*/
public function oldPersonalPrefixIsOnlyInTheMigrationTestCase()
{
$this->assertFalse(defined(C\NS_CONFIGS . "PERSONAL_GROUP_PREFIX"),
"Config.php does not define PERSONAL_GROUP_PREFIX");
$this->assertEqual(
V10ResourceFolderMigration::OLD_PERSONAL_GROUP_PREFIX,
'Personal$', "the migration knows the old personal group names");
$this->assertEqual(FeedModel::CLIPBOARD_TITLE_PREFIX,
'Personal$_clipboard_', "clipboard threads keep their title");
}
}