SeekquarrySeekquarry - Yioop Repo - Seekquarry

/ tests / GroupModelTest.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\UnitTest;
use seekquarry\yioop\models\FeedModel;
use seekquarry\yioop\models\GroupModel;
use seekquarry\yioop\models\ImpressionModel;
use seekquarry\yioop\models\UserModel;
use seekquarry\yioop\models\WikiModel;
use seekquarry\yioop\models\Model;
use seekquarry\yioop\models\ProfileModel;
use seekquarry\yioop\models\datasources\Sqlite3Manager;

/**
 * GroupModelTest checks what GroupModel, WikiModel and FeedModel count,
 * let go of, export and import for a group. The counting cases set up
 * groups, members, wiki pages, threads and posts. The letting-go cases set
 * up one conversation, which is kept twice, once for each of the two
 * people in it, and ask who may let go of which copy. Other cases check
 * the resource folders of pages, and the reading and writing of pages,
 * histories and threads that an export and an import do.
 *
 * @author Chris Pollett
 */
class GroupModelTest extends UnitTest
{
    /**
     * TEST_DIR is the folder, under the tests folder, in which each case's
     * throwaway database is made.
     */
    const TEST_DIR = '/test_files/group_model_message_delete';
    /**
     * WRITER_GROUP is the id of the group in which the writer of a message
     * keeps their own copy of the conversation.
     */
    const WRITER_GROUP = 60;
    /**
     * READER_GROUP is the id of the group in which the person written to
     * keeps their copy of the conversation.
     */
    const READER_GROUP = 70;
    /**
     * THREAD is the id of the post that starts the conversation. The
     * messages in both copies of the conversation are replies to it.
     */
    const THREAD = 500;
    /**
     * WRITER is the user id of the person who wrote the messages.
     */
    const WRITER = 3;
    /**
     * READER is the user id of the person the messages were written to.
     */
    const READER = 4;
    /**
     * $db holds the throwaway SQLite database each case works on. setUp
     * makes its tables from the statements ProfileModel uses.
     * @var object
     */
    public $db;
    /**
     * $db_path holds the path of the file of the throwaway database.
     * @var string
     */
    public $db_path;
    /**
     * $where holds the path of the folder the throwaway database is in.
     * @var string
     */
    public $where;
    /**
     * $group_model holds the GroupModel the cases use, which works on the
     * throwaway database.
     * @var object
     */
    public $group_model;
    /**
     * $wiki_model holds the WikiModel the cases read and write wiki pages
     * through, which works on the throwaway database.
     * @var WikiModel
     */
    public $wiki_model;
    /**
     * $feed_model holds the FeedModel the cases read and write posts
     * through, which works on the throwaway database.
     * @var FeedModel
     */
    public $feed_model;
    /**
     * setUp makes a throwaway database before each case. It puts in what
     * the counting cases count, and one conversation for the letting-go
     * cases. Each case gets a database of its own, so what one case
     * deletes is still there for the next.
     */
    public function setUp()
    {
        $this->where = __DIR__ . self::TEST_DIR;
        if (!file_exists($this->where)) {
            mkdir($this->where, 0777, true);
        }
        $this->db_path = $this->where . "/group" . uniqid() . ".db";
        $this->db = new Sqlite3Manager();
        $this->db->connect("", "", "", $this->db_path);
        /* The throwaway database need not outlast a power cut, so its
           writes are not waited onto the disk, which keeps the cases
           that write many rows short. */
        $this->db->execute("PRAGMA synchronous = OFF");
        $dbinfo = ["DBMS" => "Sqlite3", "DB_HOST" => ""];
        $profile = new ProfileModel(C\DB_NAME, false);
        $profile->initializeSql($this->db, $dbinfo);
        foreach (['SOCIAL_GROUPS', 'USER_GROUP', 'GROUP_PAGE',
            'GROUP_ITEM', 'ITEM_IMPRESSION_STAT',
            'ITEM_IMPRESSION_SUMMARY', 'GROUP_CALL',
            'GROUP_CALL_EVENTS'] as $table) {
            $this->db->execute($profile->create_statements[$table]);
        }
        GroupModel::$resource_folders = [];
        ImpressionModel::$log_folder = $this->where . "/impressions";
        $this->group_model = new GroupModel(C\DB_NAME, false);
        $this->feed_model = new FeedModel(C\DB_NAME, false);
        $this->group_model->db = $this->db;
        $this->wiki_model = new WikiModel(C\DB_NAME, false);
        $this->wiki_model->db = $this->db;
        $this->feed_model->db = $this->db;
        foreach ([$this->group_model, $this->wiki_model, $this->feed_model] as
            $model) {
            $model->resource_path = $this->where . "/resources";
        }
        /* user 5 owns groups 100 and 101, user 6 owns group 102 */
        foreach ([[100, 5], [101, 5], [102, 6]] as $group) {
            $this->db->execute("INSERT INTO SOCIAL_GROUPS (GROUP_ID, " .
                "GROUP_NAME, OWNER_ID) VALUES (?, ?, ?)",
                [$group[0], "Group " . $group[0], $group[1]]);
        }
        /* group 100 has three members */
        foreach ([5, 6, 7] as $member) {
            $this->group_model->addUserGroup($member, 100);
        }
        /* group 100 has two wiki pages */
        foreach ([1, 2] as $page_id) {
            $this->db->execute("INSERT INTO GROUP_PAGE (ID, GROUP_ID, " .
                "TITLE) VALUES (?, ?, ?)", [$page_id, 100, "Page $page_id"]);
        }
        /* group 100 has two threads (a top-level item has PARENT_ID = ID);
           the first thread has two replies, so it holds three posts */
        $rows = [[1, 1], [2, 2], [3, 1], [4, 1]];
        foreach ($rows as $row) {
            $this->db->execute("INSERT INTO GROUP_ITEM (ID, PARENT_ID, " .
                "GROUP_ID, USER_ID, TITLE, DESCRIPTION) " .
                "VALUES (?, ?, ?, ?, ?, ?)",
                [$row[0], $row[1], 100, 5, "Item " . $row[0], "body"]);
        }
        /* one conversation: a row it hangs from in each person's group,
           carrying no words, and two messages the writer sent, each
           kept once in each group */
        $this->addItem(self::THREAD, self::WRITER_GROUP, self::WRITER,
            self::THREAD, "");
        $this->addItem(self::THREAD + 10, self::READER_GROUP,
            self::READER, self::THREAD, "");
        $this->addItem(501, self::WRITER_GROUP, self::WRITER, self::THREAD);
        $this->addItem(502, self::READER_GROUP, self::WRITER, self::THREAD);
        $this->addItem(503, self::WRITER_GROUP, self::WRITER, self::THREAD);
        $this->addItem(504, self::READER_GROUP, self::WRITER, self::THREAD);
    }
    /**
     * addItem puts one post in the throwaway database's GROUP_ITEM table,
     * with the id, group, writer and thread a case asks for.
     *
     * @param int $item_id the row's own id
     * @param int $group_id whose group the row sits in
     * @param int $user_id who wrote it
     * @param int $parent_id the id of the post that starts the thread
     *      the post is in, or the post's own id for a post that starts one
     * @param string $description what the message says, empty for the
     *      row a conversation hangs from
     */
    private function addItem($item_id, $group_id, $user_id, $parent_id,
        $description = "a message")
    {
        $this->db->execute("INSERT INTO GROUP_ITEM (ID, PARENT_ID, " .
            "GROUP_ID, USER_ID, TITLE, DESCRIPTION, PUBDATE) " .
            "VALUES (?, ?, ?, ?, ?, ?, ?)",
            [$item_id, $parent_id, $group_id, $user_id, "3-4",
            $description, 1000]);
    }
    /**
     * countIn says how many posts are left in a group of the throwaway
     * database, so a letting-go case can see what was deleted.
     *
     * @param int $group_id the group whose posts to count
     * @return int how many posts the group holds
     */
    private function countIn($group_id)
    {
        $result = $this->db->execute("SELECT COUNT(*) AS NUM FROM " .
            "GROUP_ITEM WHERE GROUP_ID=?", [$group_id]);
        $row = $this->db->fetchArray($result);
        return intval($row['NUM']);
    }
    /**
     * sitePublicPageId gives the id of a wiki page of the site's own Public
     * group, the one with the lowest id. The thumbnail cases write their
     * files into that page's resource folder. They used to name page 1,
     * which an import that deletes the Public pages first leaves to no
     * page, since the import makes every page again under a new id.
     *
     * @param object $model a WikiModel on the site's own database
     * @return int the id of a Public group page that exists
     */
    private function sitePublicPageId($model)
    {
        $row = $model->db->fetchArray($model->db->execute("SELECT " .
            "MIN(ID) AS ID FROM GROUP_PAGE WHERE GROUP_ID = ?",
            [C\PUBLIC_GROUP_ID]));
        return intval($row['ID']);
    }
    /**
     * tearDown closes the throwaway database after each case and deletes
     * it with the folder it was in.
     */
    public function tearDown()
    {
        if ($this->db) {
            $this->db->disconnect();
        }
        if ($this->where && file_exists($this->where)) {
            $model = new Model();
            $model->db->unlinkRecursive($this->where);
        }
        ImpressionModel::$log_folder = C\LOG_DIR . "/" .
            ImpressionModel::IMPRESSION_FOLDER;
    }
    /**
     * readerMayLetGoOfTheirOwnCopyTestCase checks that the person written
     * to can delete their own copy of a message someone else wrote. That
     * is what lets a message stay gone from one person's list while the
     * other person's list keeps it.
     */
    public function readerMayLetGoOfTheirOwnCopyTestCase()
    {
        $gone = $this->group_model->deleteMessageCopy(502, self::READER,
            self::READER_GROUP);
        $this->assertEqual($gone, 1,
            "the person written to lets go of their copy");
        $this->assertEqual($this->countIn(self::READER_GROUP), 2,
            "one row fewer is left in their group");
        $this->assertEqual($this->countIn(self::WRITER_GROUP), 3,
            "and the writer's copy is left where it was");
    }
    /**
     * writerMayLetGoOfTheirOwnCopyTestCase checks that the writer can
     * delete their own copy of a message, and that the other person's copy
     * stays.
     */
    public function writerMayLetGoOfTheirOwnCopyTestCase()
    {
        $gone = $this->group_model->deleteMessageCopy(501, self::WRITER,
            self::WRITER_GROUP);
        $this->assertEqual($gone, 1, "the writer lets go of their copy");
        $this->assertEqual($this->countIn(self::WRITER_GROUP), 2,
            "one row fewer is left in their group");
        $this->assertEqual($this->countIn(self::READER_GROUP), 3,
            "and the other person's copy is left where it was");
    }
    /**
     * outsiderMayNotLetGoTestCase asks the model to let go of a message
     * that is in neither the asker's group nor of their writing, and
     * checks the message stays.
     */
    public function outsiderMayNotLetGoTestCase()
    {
        $gone = $this->group_model->deleteMessageCopy(501, 9, 90);
        $this->assertEqual($gone, 0,
            "someone with no part in the message lets go of nothing");
        $this->assertEqual($this->countIn(self::WRITER_GROUP), 3,
            "and every row is left where it was");
    }
    /**
     * wholeConversationGoesForOnePersonTestCase checks that deleting a
     * whole conversation for one person deletes every message that person
     * holds in it. The post that starts the conversation stays, and so do
     * the other person's copies.
     */
    public function wholeConversationGoesForOnePersonTestCase()
    {
        $gone = $this->group_model->deleteConversationCopies(self::THREAD,
            self::READER_GROUP);
        $this->assertEqual($gone, 2,
            "both messages that person held are let go");
        $this->assertEqual($this->countIn(self::READER_GROUP), 1,
            "the thread they hang from is kept");
        $this->assertEqual($this->countIn(self::WRITER_GROUP), 3,
            "and the other person's conversation is left whole");
    }
    /**
     * conversationLeavesOneListOnlyTestCase checks that taking a
     * conversation off one person's list deletes the post that starts it in
     * that person's group. The other person's starting post stays, so the
     * conversation stays on the other person's list.
     */
    public function conversationLeavesOneListOnlyTestCase()
    {
        $gone = $this->feed_model->deleteConversationThreadRow(
            self::READER_GROUP, "3-4");
        $this->assertEqual($gone, 1,
            "the row it hung from in that group is let go");
        $this->assertEqual($this->countIn(self::READER_GROUP), 2,
            "the messages under it are left for a separate letting go");
        $this->assertEqual($this->countIn(self::WRITER_GROUP), 3,
            "and the other person's conversation is left whole");
    }
    /**
     * countGroupsOwnedByUserTestCase checks that countGroupsOwnedByUser
     * counts the groups each user owns and no others.
     */
    public function countGroupsOwnedByUserTestCase()
    {
        $this->assertEqual(2,
            (int)$this->group_model->countGroupsOwnedByUser(5),
            "user 5 owns two groups");
        $this->assertEqual(1,
            (int)$this->group_model->countGroupsOwnedByUser(6),
            "user 6 owns one group");
        $this->assertEqual(0,
            (int)$this->group_model->countGroupsOwnedByUser(9),
            "a user owning nothing counts zero");
    }
    /**
     * countGroupUsersTestCase checks that countGroupUsers counts the
     * members setUp gave a group.
     */
    public function countGroupUsersTestCase()
    {
        $this->assertEqual(3,
            (int)$this->group_model->countGroupUsers(100),
            "group 100 has three members");
    }
    /**
     * countGroupWikiPagesTestCase checks that countGroupWikiPages counts
     * the wiki pages of a group and no others.
     */
    public function countGroupWikiPagesTestCase()
    {
        $this->assertEqual(2,
            (int)$this->wiki_model->countGroupWikiPages(100),
            "group 100 has two wiki pages");
        $this->assertEqual(0,
            (int)$this->wiki_model->countGroupWikiPages(101),
            "group 101 has no wiki pages");
    }
    /**
     * wipeGroupWikiLeavesOtherGroupsTestCase checks that wiping a group's
     * wiki deletes its pages, their histories, links and discussion
     * threads with their posts, and leaves another group's pages and
     * threads as they were.
     */
    public function wipeGroupWikiLeavesOtherGroupsTestCase()
    {
        $profile = new ProfileModel(C\DB_NAME, false);
        $profile->initializeSql($this->db, ["DBMS" => "Sqlite3",
            "DB_HOST" => ""]);
        foreach (['GROUP_PAGE_HISTORY', 'GROUP_PAGE_LINK',
            'GROUP_PAGE_PRE_LINK'] as $table) {
            $this->db->execute($profile->create_statements[$table]);
        }
        /* group 100's pages 1 and 2 are wiped; group 101's page 3 stays,
           and so does its thread, item 2 */
        $this->db->execute("UPDATE GROUP_PAGE SET DISCUSS_THREAD = 1 " .
            "WHERE ID = 1");
        $this->db->execute("INSERT INTO GROUP_PAGE (ID, GROUP_ID, TITLE, " .
            "DISCUSS_THREAD) VALUES (3, 101, 'Kept', 2)");
        foreach ([[1, 100], [2, 100], [3, 101]] as [$page, $group]) {
            $this->db->execute("INSERT INTO GROUP_PAGE_HISTORY (PAGE_ID, " .
                "GROUP_ID, PUBDATE) VALUES (?, ?, 1)", [$page, $group]);
        }
        $this->db->execute("INSERT INTO GROUP_PAGE_LINK VALUES (1, 3, 1)");
        $this->db->execute("INSERT INTO GROUP_PAGE_PRE_LINK VALUES " .
            "(1, 2, 100, 'Somewhere')");
        $this->wiki_model->wipeGroupWiki(100);
        $count = function ($sql) {
            $row = $this->db->fetchArray($this->db->execute($sql));
            return intval($row['NUM']);
        };
        $this->assertEqual($count("SELECT COUNT(*) AS NUM FROM GROUP_PAGE"),
            1, "only the other group's page is left");
        $this->assertEqual($count("SELECT COUNT(*) AS NUM FROM " .
            "GROUP_PAGE_HISTORY"), 1, "only its history is left");
        $this->assertEqual($count("SELECT COUNT(*) AS NUM FROM " .
            "GROUP_PAGE_LINK") + $count("SELECT COUNT(*) AS NUM FROM " .
            "GROUP_PAGE_PRE_LINK"), 0, "links to and from wiped pages go");
        $this->assertEqual($count("SELECT COUNT(*) AS NUM FROM GROUP_ITEM " .
            "WHERE PARENT_ID = 1"), 0,
            "the wiped page's thread and its posts go");
        $this->assertEqual($count("SELECT COUNT(*) AS NUM FROM GROUP_ITEM " .
            "WHERE PARENT_ID = 2"), 1, "the other group's thread stays");
    }
    /**
     * repositoryPageGoesWithItsIssuesTestCase checks that deleting a git
     * repository's wiki page also deletes the pages its issues, waiting
     * reports and bans are kept in, with their histories. A page whose name
     * only starts the same way, the same issue name in another group and
     * the same issue name in another locale are left as they were.
     */
    public function repositoryPageGoesWithItsIssuesTestCase()
    {
        $profile = new ProfileModel(C\DB_NAME, false);
        $profile->initializeSql($this->db, ["DBMS" => "Sqlite3",
            "DB_HOST" => ""]);
        $this->db->execute($profile->create_statements['GROUP_PAGE_HISTORY']);
        $pages = [[20, 102, 'repo', 'en-US'], [21, 102, 'repo$$1', 'en-US'],
            [22, 102, 'repo$?1', 'en-US'], [23, 102, 'repo$~token', 'en-US'],
            [24, 102, 'repository', 'en-US'], [25, 101, 'repo$$1', 'en-US'],
            [26, 102, 'repo$$1', 'fr-FR']];
        foreach ($pages as [$id, $group, $title, $locale]) {
            $this->db->execute("INSERT INTO GROUP_PAGE (ID, GROUP_ID, " .
                "TITLE, LOCALE_TAG, PAGE) VALUES (?, ?, ?, ?, 'html')",
                [$id, $group, $title, $locale]);
            $this->db->execute("INSERT INTO GROUP_PAGE_HISTORY (PAGE_ID, " .
                "GROUP_ID, TITLE, PAGE, LOCALE_TAG, PUBDATE) VALUES " .
                "(?, ?, ?, 'text', ?, 1)", [$id, $group, $title, $locale]);
        }
        $this->assertTrue($this->wiki_model->deleteGroupPage(102, 'repo',
            'en-US'), "the repository page was there to delete");
        $left = function ($table, $column) {
            $result = $this->db->execute("SELECT $column AS ID FROM $table " .
                "WHERE $column >= 20 ORDER BY $column");
            $ids = [];
            while ($row = $this->db->fetchArray($result)) {
                $ids[] = intval($row['ID']);
            }
            return $ids;
        };
        $this->assertEqual($left("GROUP_PAGE", "ID"), [24, 25, 26],
            "the issue, waiting report and ban pages went with it");
        $this->assertEqual($left("GROUP_PAGE_HISTORY", "PAGE_ID"),
            [24, 25, 26], "and so did their histories");
    }
    /**
     * pageCommentsGoToAnotherPageTestCase checks that the posts of a wiki
     * page's thread, read by groupPageComments as an export reads them,
     * are written by setPageComments onto another page's thread as an
     * import writes them: the starting post first, the replies in the order
     * they were posted, each writer found by user name, a writer this site
     * has no user for given to root, and the replies the thread held before
     * replaced.
     */
    public function pageCommentsGoToAnotherPageTestCase()
    {
        $profile = new ProfileModel(C\DB_NAME, false);
        $profile->initializeSql($this->db, ["DBMS" => "Sqlite3",
            "DB_HOST" => ""]);
        $this->db->execute($profile->create_statements['USERS']);
        foreach ([[C\ROOT_ID, 'root'], [8, 'writer'], [9, 'gone']] as
            [$user_id, $name]) {
            $this->db->execute("INSERT INTO USERS (USER_ID, USER_NAME) " .
                "VALUES (?, ?)", [$user_id, $name]);
        }
        $this->db->execute("INSERT INTO GROUP_PAGE (ID, GROUP_ID, TITLE, " .
            "LOCALE_TAG, DISCUSS_THREAD) VALUES (30, 102, 'Talked', " .
            "'en-US', 40), (31, 102, 'Copy', 'en-US', 50)");
        $this->addItem(40, 102, 8, 40, "starts the thread");
        $this->addItem(42, 102, 9, 40, "later reply");
        $this->addItem(41, 102, C\ROOT_ID, 40, "earlier reply");
        $this->db->execute("UPDATE GROUP_ITEM SET PUBDATE = ID WHERE " .
            "ID >= 40");
        $this->addItem(50, 102, C\ROOT_ID, 50, "copy's own start");
        $this->addItem(51, 102, C\ROOT_ID, 50, "copy's own reply");
        $posts = $this->wiki_model->groupPageComments(102);
        $this->assertEqual(array_column($posts["en-US"]["Talked"],
            "description"), ["starts the thread", "earlier reply",
            "later reply"], "the starting post comes first, then replies");
        $this->db->execute("DELETE FROM USERS WHERE USER_ID = 9");
        $this->assertTrue($this->wiki_model->setPageComments(102, 'Copy',
            'en-US', $posts["en-US"]["Talked"]), "the copy has a thread");
        $result = $this->db->execute("SELECT ID, USER_ID, DESCRIPTION, " .
            "PUBDATE FROM GROUP_ITEM WHERE PARENT_ID = 50 ORDER BY PUBDATE");
        $rows = [];
        while ($row = $this->db->fetchArray($result)) {
            $rows[] = [intval($row['USER_ID']), $row['DESCRIPTION'],
                intval($row['PUBDATE'])];
        }
        $this->assertEqual($rows, [[8, "starts the thread", 40],
            [C\ROOT_ID, "earlier reply", 41],
            [C\ROOT_ID, "later reply", 42]],
            "the copy's thread holds the same posts by the same writers");
    }
    /**
     * wipeKeepsWhatTheFileLacksTestCase checks that an import that deletes
     * first, given a file carrying none of git issue pages, comments and
     * history, deletes only the pages the file does not hold: the page the
     * import wrote stays, a git repository's issue page stays, and the
     * history and discussion thread of the deleted page stay.
     */
    public function wipeKeepsWhatTheFileLacksTestCase()
    {
        $profile = new ProfileModel(C\DB_NAME, false);
        $profile->initializeSql($this->db, ["DBMS" => "Sqlite3",
            "DB_HOST" => ""]);
        foreach (['GROUP_PAGE_HISTORY', 'GROUP_PAGE_LINK',
            'GROUP_PAGE_PRE_LINK'] as $table) {
            $this->db->execute($profile->create_statements[$table]);
        }
        $this->db->execute("INSERT INTO GROUP_PAGE (ID, GROUP_ID, TITLE, " .
            "DISCUSS_THREAD) VALUES (60, 102, 'Written', 70), " .
            "(61, 102, 'Not in file', 71), (62, 102, 'repo$$1', 72)");
        foreach ([60, 61, 62] as $page_id) {
            $this->db->execute("INSERT INTO GROUP_PAGE_HISTORY (PAGE_ID, " .
                "GROUP_ID, PUBDATE) VALUES (?, 102, 1)", [$page_id]);
            $this->addItem($page_id + 10, 102, 5, $page_id + 10);
        }
        $this->wiki_model->wipeGroupWiki(102, [60], []);
        $count = function ($sql) {
            $row = $this->db->fetchArray($this->db->execute($sql));
            return intval($row['NUM']);
        };
        $this->assertEqual($count("SELECT COUNT(*) AS NUM FROM GROUP_PAGE " .
            "WHERE GROUP_ID = 102"), 2,
            "the written page and the issue page stay");
        $this->assertEqual($count("SELECT COUNT(*) AS NUM FROM " .
            "GROUP_PAGE_HISTORY") + $count("SELECT COUNT(*) AS NUM FROM " .
            "GROUP_ITEM WHERE ID BETWEEN 70 AND 79"), 6,
            "no history and no thread is deleted");
        $this->wiki_model->wipeGroupWiki(102, [60],
            ["issue_pages", "comments", "history"]);
        $this->assertEqual($count("SELECT COUNT(*) AS NUM FROM GROUP_PAGE " .
            "WHERE GROUP_ID = 102") + $count("SELECT COUNT(*) AS NUM FROM " .
            "GROUP_PAGE_HISTORY") + $count("SELECT COUNT(*) AS NUM FROM " .
            "GROUP_ITEM WHERE ID BETWEEN 70 AND 79"), 5, "with all three " .
            "carried, the issue page goes with its history and thread");
    }
    /**
     * pageHistoryGoesToAnotherPageTestCase checks that the revisions of a
     * page, read by pageHistory as an export reads them, are written by
     * setPageHistory onto another page as an import writes them, in place
     * of that page's revisions, each editor found by user name.
     */
    public function pageHistoryGoesToAnotherPageTestCase()
    {
        $profile = new ProfileModel(C\DB_NAME, false);
        $profile->initializeSql($this->db, ["DBMS" => "Sqlite3",
            "DB_HOST" => ""]);
        foreach (['USERS', 'GROUP_PAGE_HISTORY'] as $table) {
            $this->db->execute($profile->create_statements[$table]);
        }
        $this->db->execute("INSERT INTO USERS (USER_ID, USER_NAME) VALUES " .
            "(8, 'writer')");
        $this->db->execute("INSERT INTO GROUP_PAGE (ID, GROUP_ID, TITLE, " .
            "LOCALE_TAG) VALUES (80, 102, 'Old', 'en-US'), " .
            "(81, 102, 'Copy', 'en-US')");
        foreach ([[80, 8, 'first', 'text one', 1], [80, 8, 'second',
            'text two', 2], [81, 8, 'own', 'own text', 3]] as
            [$page_id, $editor, $why, $text, $pubdate]) {
            $this->db->execute("INSERT INTO GROUP_PAGE_HISTORY (PAGE_ID, " .
                "EDITOR_ID, GROUP_ID, TITLE, PAGE, LOCALE_TAG, PUBDATE, " .
                "EDIT_COMMENT) VALUES (?, ?, 102, '', ?, 'en-US', ?, ?)",
                [$page_id, $editor, $text, $pubdate, $why]);
        }
        $revisions = $this->wiki_model->pageHistory(80);
        $this->assertEqual(array_column($revisions, "edit_comment"),
            ["first", "second"], "the revisions come oldest first");
        $this->wiki_model->setPageHistory(102, 'Copy', 'en-US', $revisions);
        $this->assertEqual($this->wiki_model->pageHistory(81), $revisions,
            "the copy holds the same revisions by the same editor");
    }
    /**
     * discussionThreadGoesToAnotherGroupTestCase checks that a thread of a
     * group's discussion, read by groupDiscussions as an export reads it,
     * is made again in another group by addDiscussionThread as an import
     * makes it, and that a wiki page's thread is not one of the group's
     * discussions. Importing the thread again adds nothing, and importing
     * it with one more reply adds just that reply.
     */
    public function discussionThreadGoesToAnotherGroupTestCase()
    {
        $profile = new ProfileModel(C\DB_NAME, false);
        $profile->initializeSql($this->db, ["DBMS" => "Sqlite3",
            "DB_HOST" => ""]);
        $this->db->execute($profile->create_statements['USERS']);
        $this->db->execute("INSERT INTO USERS (USER_ID, USER_NAME) VALUES " .
            "(8, 'writer')");
        $this->db->execute("INSERT INTO GROUP_PAGE (ID, GROUP_ID, TITLE, " .
            "DISCUSS_THREAD) VALUES (90, 102, 'Talked', 95)");
        $this->addItem(95, 102, 8, 95, "a page's thread");
        $this->addItem(96, 102, 8, 96, "a thread");
        $this->addItem(97, 102, 8, 96, "its reply");
        $threads = iterator_to_array($this->feed_model->groupDiscussions(
            102), false);
        $this->assertEqual(count($threads), 1,
            "only the thread that is not a page's is a discussion");
        $posts = $threads[0][1];
        $this->assertEqual(array_column($posts, "description"),
            ["a thread", "its reply"], "the thread's posts in order");
        [$new_thread, $is_new, $added] =
            $this->feed_model->addDiscussionThread(101, $posts);
        $copied = iterator_to_array($this->feed_model->groupDiscussions(
            101), false);
        $this->assertEqual([$is_new, $added, $copied[count($copied) - 1][0],
            array_column($copied[count($copied) - 1][1], "description")],
            [true, 2, $new_thread, ["a thread", "its reply"]],
            "the other group has the same thread");
        $this->assertEqual($this->feed_model->addDiscussionThread(101,
            $posts), [$new_thread, false, 0],
            "the same thread imported again adds nothing");
        $posts[] = ["user_name" => "writer", "title" => "",
            "description" => "a newer reply", "pubdate" => 2000];
        $this->assertEqual($this->feed_model->addDiscussionThread(101,
            $posts), [$new_thread, false, 1],
            "a newer export of the thread adds just its new reply");
    }
    /**
     * latestPagesUseThePageNamesOfNowTestCase checks that the pages an
     * export writes are named as the pages are named now, not as their
     * latest revision was saved. A git issue page renamed from repo$1 to
     * repo$$1 by version 124 kept repo$1 in its revisions, so the export
     * of seekquarry.com held repo$1, and an import made it a second page.
     * A revision whose page has been deleted is not exported. Each page
     * comes with the time of its latest revision, and an issue page is
     * left out when the export is asked to leave such pages out.
     */
    public function latestPagesUseThePageNamesOfNowTestCase()
    {
        $profile = new ProfileModel(C\DB_NAME, false);
        $profile->initializeSql($this->db, ["DBMS" => "Sqlite3",
            "DB_HOST" => ""]);
        $this->db->execute($profile->create_statements['GROUP_PAGE_HISTORY']);
        $this->db->execute("INSERT INTO GROUP_PAGE (ID, GROUP_ID, TITLE, " .
            "LOCALE_TAG) VALUES (10, 102, 'repo$$1', 'en-US')");
        $rows = [[10, 'repo$1', 'old text', 1], [10, 'repo$1', 'new text', 2],
            [11, 'Gone', 'deleted page', 1]];
        foreach ($rows as [$page_id, $title, $page, $pubdate]) {
            $this->db->execute("INSERT INTO GROUP_PAGE_HISTORY (PAGE_ID, " .
                "GROUP_ID, TITLE, PAGE, LOCALE_TAG, PUBDATE) VALUES " .
                "(?, 102, ?, ?, 'en-US', ?)", [$page_id, $title, $page,
                $pubdate]);
        }
        $this->assertEqual($this->wiki_model->latestGroupPages(102),
            ["en-US" => ['repo$$1' => "new text"]],
            "the latest text, under the page's name of now, and no " .
            "deleted page");
        $pubdates = [];
        $this->wiki_model->latestGroupPages(102, "", $pubdates);
        $this->assertEqual($pubdates, ["en-US" => ['repo$$1' => 2]],
            "each page comes with the time of its latest revision");
        $this->assertEqual($this->wiki_model->latestGroupPages(102, "",
            $pubdates, false), [], "an issue page is left out when asked");
    }
    /**
     * countGroupThreadsTestCase checks that countGroupThreads counts the
     * posts that start a thread in a group, and not the replies.
     */
    public function countGroupThreadsTestCase()
    {
        $this->assertEqual(2,
            (int)$this->feed_model->countGroupThreads(100),
            "group 100 has two threads, not counting replies");
    }
    /**
     * thumbOfDocumentDrawsItsPictureTestCase checks that a file a page asks
     * to show as a thumb is drawn as the small picture kept beside it,
     * whatever kind of file it is. So a document shows a picture of its
     * front page rather than opening in a frame of its own. The picture is
     * kept in the page's thumbs folder under the file's name.
     */
    public function thumbOfDocumentDrawsItsPictureTestCase()
    {
        $model = new WikiModel();
        $model->resource_path = $this->where . "/resources";
        $page_id = $this->sitePublicPageId($model);
        $folders = $model->getGroupPageResourcesFolders(C\PUBLIC_GROUP_ID,
            $page_id, "", true);
        $written = $folders[0] . "/notes.pdf";
        file_put_contents($written, "a document stands here");
        $beside = $folders[1] . "/notes.pdf.webp";
        file_put_contents($beside, "a picture stands here");
        $page = "<p>((resource-thumb:notes.pdf|The notes))</p>";
        $drawn = $model->insertResourcesParsePage(C\PUBLIC_GROUP_ID,
            $page_id, "en-US", $page, "", "group");
        unlink($beside);
        $bare = $model->insertResourcesParsePage(C\PUBLIC_GROUP_ID,
            $page_id, "en-US", $page, "", "group");
        unlink($written);
        $this->assertTrue(strpos($bare, "wd/thumbs/") === false,
            "a file with no picture beside it does not point at one");
        $this->assertTrue(strpos($drawn, "wd/thumbs/") !== false,
            "a thumb of a document points at the picture beside it");
        $this->assertTrue(strpos($drawn, "<iframe") === false,
            "a thumb of a document is not opened in a frame");
    }
    /**
     * listedFileWithNoBytesGetsNoThumbAndNoComplaintTestCase checks
     * asking for the small picture of a file a listing names when the
     * file itself never arrived, as with a feed item that was promised
     * and not downloaded. The answer is no picture, said quietly: the
     * old read warned about the missing file on every listing of its
     * folder.
     */
    public function listedFileWithNoBytesGetsNoThumbAndNoComplaintTestCase()
    {
        $model = new WikiModel();
        $model->resource_path = $this->where . "/resources";
        $page_id = $this->sitePublicPageId($model);
        $folders = $model->getGroupPageResourcesFolders(C\PUBLIC_GROUP_ID,
            $page_id, "", true);
        $made = $model->makeThumbStripExif("never_arrived.webp",
            $folders[0], $folders[1]);
        $this->assertEqual(false, $made,
            "a missing file yields no picture and no warning");
    }
    /**
     * callRingsOnlyAtTheOtherEndTestCase checks the answer the messages
     * screen uses to decide whose name pulses. A call the other side
     * started is ringing for the reader; the same call asked about from
     * the caller's own end is not, since a person is not rung by their
     * own call. A call that has been turned down rings for nobody.
     */
    public function callRingsOnlyAtTheOtherEndTestCase()
    {
        $reader_id = 5;
        $caller_id = 7;
        $members = [$reader_id, $caller_id];
        asort($members);
        $call_hash_id = L\crawlHash(implode("-", $members));
        $this->assertEqual([],
            $this->group_model->callsRingingFor([$call_hash_id],
            $reader_id), "with no call nothing is ringing");
        $this->group_model->createGroupCall($call_hash_id, $caller_id);
        $ringing = $this->group_model->callsRingingFor([$call_hash_id],
            $reader_id);
        $this->assertEqual($caller_id, $ringing[$call_hash_id] ?? 0,
            "a call the other side started rings for the reader");
        $this->assertEqual([],
            $this->group_model->callsRingingFor([$call_hash_id],
            $caller_id), "the caller is not rung by their own call");
        $this->group_model->deleteGroupCall($call_hash_id);
        $this->assertEqual([],
            $this->group_model->callsRingingFor([$call_hash_id],
            $reader_id), "a call turned down rings for nobody");
    }
    /**
     * webpInASubFolderIsSeenToHaveItsThumbnailTestCase checks the
     * media list manifest for a webp uploaded into a sub-folder: the
     * resource must be seen as having a thumbnail, so the listing links
     * the thumbnail rather than falling back to a default icon. The
     * folder listing globs its files, and globbing the folder through
     * a regular-expression escape rather than a glob one missed every
     * file on a path holding punctuation, so nothing was seen to have a
     * thumbnail.
     */
    public function webpInASubFolderIsSeenToHaveItsThumbnailTestCase()
    {
        $model = new WikiModel();
        $model->resource_path = $this->where . "/resources";
        $page_id = $this->sitePublicPageId($model);
        $folders = $model->getGroupPageResourcesFolders(C\PUBLIC_GROUP_ID,
            $page_id, "Andrew", true);
        $webp = imagecreatetruecolor(20, 20);
        imagewebp($webp, $folders[0] . "/shot.webp");
        $model->makeThumbStripExif("shot.webp", $folders[0],
            $folders[1]);
        $listing = $model->getGroupPageResourceUrls(C\PUBLIC_GROUP_ID,
            $page_id, "Andrew");
        /* The thumbnail is only there where the picture maker drew one,
           and Yioop's own error handler reports a removal that finds
           nothing whatever the call is written with, so each is looked
           for before it is removed. */
        foreach ([$folders[0] . "/shot.webp",
            $folders[1] . "/shot.webp.webp"] as $leftover) {
            if (file_exists($leftover)) {
                unlink($leftover);
            }
        }
        $seen = false;
        foreach ($listing["resources"] ?? [] as $resource) {
            if (($resource["name"] ?? "") === "shot.webp") {
                $seen = $resource["has_thumb"];
            }
        }
        $this->assertTrue($seen,
            "a webp in a sub-folder is seen to have its thumbnail");
    }
    /**
     * countThreadPostsTestCase checks that countThreadPosts counts the
     * post that starts a thread and each of its replies.
     */
    public function countThreadPostsTestCase()
    {
        $this->assertEqual(3,
            (int)$this->feed_model->countThreadPosts(1),
            "thread 1 holds its start item plus two replies");
        $this->assertEqual(1,
            (int)$this->feed_model->countThreadPosts(2),
            "thread 2 holds only its start item");
    }
    /**
     * freshCallIsActiveTestCase checks that a call just started reads
     * as active and one whose events have all aged out does not, so a
     * browser is told a call is on only while it is.
     */
    public function freshCallIsActiveTestCase()
    {
        $model = $this->group_model;
        $model->createGroupCall(9001, self::WRITER);
        $this->assertTrue($model->isActiveCall(9001) !== false &&
            $model->isActiveCall(9001) != 0,
            "a call just started is active");
        $model->deleteGroupCall(9002);
        $this->assertTrue(!$model->isActiveCall(9002),
            "a call that was never started is not active");
    }
    /**
     * nextCallEventReadsTheOtherSideInOrderTestCase checks that
     * nextCallEvent gives another person's next event in order and
     * passes over the reader's own, so a browser reads what the other
     * side sent rather than an echo of what it sent itself.
     */
    public function nextCallEventReadsTheOtherSideInOrderTestCase()
    {
        $model = $this->group_model;
        $start = $model->createGroupCall(9010, self::WRITER);
        $model->addCallEvent(9010, self::WRITER, "offer", ["sdp" => "a"]);
        $model->addCallEvent(9010, self::READER, "answer", ["sdp" => "b"]);
        $model->addCallEvent(9010, self::READER, "candidate",
            ["cand" => "c"]);
        /* The reader passes over the writer's offer and reads the two
           the writer sent, oldest first. */
        $first = $model->nextCallEvent(9010, self::WRITER, $start);
        $this->assertEqual("answer", $first['EVENT']['type'],
            "the writer reads the reader's answer first");
        $second = $model->nextCallEvent(9010, self::WRITER,
            $first['EVENT_TIME']);
        $this->assertEqual("candidate", $second['EVENT']['type'],
            "then the reader's candidate");
        $none = $model->nextCallEvent(9010, self::WRITER,
            $second['EVENT_TIME']);
        $this->assertTrue($none === false,
            "and nothing after the last event the reader sent");
    }
    /**
     * nextCallEventSkipsTheReadersOwnTestCase checks that a reader
     * passing over its own events reads the other party's, so the
     * writer's offer reaches the reader while the reader's own answer
     * does not come back to it.
     */
    public function nextCallEventSkipsTheReadersOwnTestCase()
    {
        $model = $this->group_model;
        $start = $model->createGroupCall(9020, self::WRITER);
        $model->addCallEvent(9020, self::WRITER, "offer", ["sdp" => "a"]);
        $model->addCallEvent(9020, self::READER, "answer", ["sdp" => "b"]);
        $reader_reads = $model->nextCallEvent(9020, self::READER, $start);
        $this->assertEqual("offer", $reader_reads['EVENT']['type'],
            "the reader reads the writer's offer");
        $writer_reads = $model->nextCallEvent(9020, self::WRITER, $start);
        $this->assertEqual("answer", $writer_reads['EVENT']['type'],
            "the writer reads the reader's answer, not its own offer");
    }
    /**
     * burstOfCallEventsKeepsEveryOneTestCase checks that a burst of
     * events a call sends keeps every one, so a candidate is not lost
     * when two events are timed to the same microsecond. The events are
     * read back in order and counted against what went in.
     */
    public function burstOfCallEventsKeepsEveryOneTestCase()
    {
        $model = $this->group_model;
        $start = $model->createGroupCall(9030, self::WRITER);
        $count = 12;
        for ($index = 0; $index < $count; $index++) {
            $model->addCallEvent(9030, self::READER, "candidate",
                ["cand" => $index]);
        }
        $seen = 0;
        $cutoff = $start;
        while (($event = $model->nextCallEvent(9030, self::WRITER,
            $cutoff)) !== false) {
            $seen++;
            $cutoff = $event['EVENT_TIME'];
            if ($seen > $count + 2) {
                break;
            }
        }
        $this->assertEqual($count, $seen,
            "every event in the burst is stored and read once");
    }
    /**
     * refreshedRingingCallOutlivesAsSlowPickupTestCase checks that
     * refreshGroupCall moves a call's time up so a call rung longer
     * than the time a caller rings for stays active,
     * and the answer's offer then
     * lands on the same call. Without the refresh a person who took
     * longer to pick up than the window found the call aged out: the
     * offer arrived to no active call and the two sides never joined.
     */
    public function refreshedRingingCallOutlivesAsSlowPickupTestCase()
    {
        $model = $this->group_model;
        $reader = self::WRITER + 1;
        /* a call whose ring began longer ago than the active window */
        $rang_at = sprintf('%.6f',
            microtime(true) - C\MAX_CALL_RING_TIME - 5.0);
        $model->deleteGroupCall(9040);
        $model->createGroupCall(9040, self::WRITER, $rang_at);
        $this->assertTrue(!$model->isActiveCall(9040),
            "a call rung longer than a caller rings for is over");
        /* the ringing side polls, which moves the call's time up */
        $model->refreshGroupCall(9040,
            sprintf('%.6f', microtime(true)));
        $this->assertTrue($model->isActiveCall(9040) !== false &&
            $model->isActiveCall(9040) != 0,
            "the refresh keeps the ringing call active");
        /* the slow pickup's offer lands and the ringer reads it */
        $model->addCallEvent(9040, $reader, 'client-offer', 'SDP');
        $event = $model->nextCallEvent(9040, self::WRITER, $rang_at);
        $this->assertTrue(!empty($event) &&
            $event['EVENT']['type'] == 'client-offer',
            "the late answer's offer reaches the side that rang");
    }
    /**
     * unusedResourceFoldersAreRemovedTestCase checks what
     * removeUnusedResourceFolders deletes from a resources folder laid
     * out as ResourceFolders lays out a site's app/resources. Kept: the
     * folders of pages 1 and 2 of Group 100, which exist; the folder of
     * a deleted group's page that page 2's redirect.txt names; post 1's
     * folder, whose page folder is a link into a deleted group, and that
     * group's folder; user writer's folder; the feed pictures; and the
     * site, domains, needs_thumbs and unmigrated folders, and a folder
     * whose name is not a fixed name. Deleted: the folder of a page no
     * longer in Group 100, the folder of Group 900, a deleted group, and
     * the folder of a user not in USERS.
     */
    public function unusedResourceFoldersAreRemovedTestCase()
    {
        $this->addUsersAndPublicGroup();
        $resources = $this->where . "/resources" . uniqid();
        $page_one = $this->makePageFolder(L\ResourceFolders::wikiPagePath(
            "Group 100", "en-US", "Page 1", $resources));
        $page_two = $this->makePageFolder(L\ResourceFolders::wikiPagePath(
            "Group 100", "en-US", "Page 2", $resources));
        $gone_page = $this->makePageFolder(L\ResourceFolders::wikiPagePath(
            "Group 100", "en-US", "Gone", $resources));
        $dead_group = $this->makePageFolder(L\ResourceFolders::wikiPagePath(
            "Group 900", "en-US", "Page 9", $resources));
        $elsewhere = $this->makePageFolder(L\ResourceFolders::wikiPagePath(
            "Group 901", "en-US", "Old", $resources));
        file_put_contents("$page_two/page/redirect.txt", "$elsewhere/page");
        $link_target = $this->makePageFolder(L\ResourceFolders::postPath(
            "Group 902", "writer", 7, $resources));
        $post = L\ResourceFolders::postPath("Group 100", "writer", 0,
            $resources);
        mkdir("$post/thumbs", 0777, true);
        symlink(L\ResourceFolders::relativeLinkTarget("$post/page",
            "$link_target/page"), "$post/page");
        $users = [];
        foreach (["writer", "nobody"] as $user_name) {
            $users[$user_name] = L\ResourceFolders::userPath($user_name,
                $resources);
            mkdir($users[$user_name], 0777, true);
            file_put_contents($users[$user_name] . "/user_icon.webp", "icon");
        }
        $feeds = L\ResourceFolders::feedsPath($resources) . "/2026-10-01";
        mkdir($feeds, 0777, true);
        file_put_contents("$feeds/picture.jpg", "picture");
        $other_name = "abc/abc" . substr(L\crawlHash("other"), 3);
        foreach (["site", "domains/www.example.com", "needs_thumbs",
            "unmigrated", $other_name] as $named) {
            mkdir("$resources/$named", 0777, true);
        }
        $this->group_model->removeUnusedResourceFolders($resources);
        $this->assertEqual([is_file("$page_one/page/file.txt"),
            is_file("$page_two/page/redirect.txt"),
            is_file("$elsewhere/page/file.txt"),
            is_file("$post/page/file.txt"),
            is_file($users["writer"] . "/user_icon.webp"),
            is_file("$feeds/picture.jpg")], array_fill(0, 6, true),
            "the folders of pages, posts, users, a redirect, a link and " .
            "the feed pictures are kept");
        $this->assertEqual([is_dir($gone_page),
            is_dir(L\ResourceFolders::groupPath("Group 900", $resources)),
            is_dir($users["nobody"])], [false, false, false],
            "a deleted page's folder, a deleted group's folder and the " .
            "folder of a user not in USERS are deleted");
        $this->assertEqual([is_dir("$resources/site"),
            is_dir("$resources/domains/www.example.com"),
            is_dir("$resources/needs_thumbs"),
            is_dir("$resources/unmigrated"),
            is_dir("$resources/$other_name")], [true, true, true, true,
            true], "the site, domains, needs_thumbs and unmigrated folders " .
            "and a folder whose name is no fixed name stay");
        /* unlinkRecursive would follow the link, so it goes first */
        unlink("$post/page");
        $this->db->unlinkRecursive($resources, true);
    }
    /**
     * emptyPageFoldersAreRemovedTestCase checks that
     * removeUnusedResourceFolders deletes the folder of a page in use
     * that holds no file a person put there: page 1's page folder holds
     * only an empty .archive and a .DS_Store, and its thumbs folder only
     * an empty sub-folder. Page 2's page and thumbs folders each hold an
     * empty folder named Andrew, made by a person, and stay, so Group
     * 100's folder stays. The users folder, holding only a .DS_Store,
     * goes.
     */
    public function emptyPageFoldersAreRemovedTestCase()
    {
        $this->addUsersAndPublicGroup();
        $resources = $this->where . "/resources" . uniqid();
        $page = $this->makePageFolder(L\ResourceFolders::wikiPagePath(
            "Group 100", "en-US", "Page 1", $resources));
        unlink("$page/page/file.txt");
        unlink("$page/thumbs/file.txt.webp");
        mkdir("$page/page/.archive");
        file_put_contents("$page/page/.DS_Store", "finder");
        mkdir("$page/thumbs/2026-10-19");
        $made = $this->makePageFolder(L\ResourceFolders::wikiPagePath(
            "Group 100", "en-US", "Page 2", $resources));
        foreach (["page/file.txt", "thumbs/file.txt.webp"] as $file) {
            unlink("$made/$file");
            mkdir(dirname("$made/$file") . "/Andrew");
        }
        mkdir("$resources/users", 0777, true);
        file_put_contents("$resources/users/.DS_Store", "finder");
        $this->group_model->removeUnusedResourceFolders($resources);
        $this->assertEqual([is_dir($page), is_dir("$resources/users")],
            [false, false], "folders holding no file a person put there go");
        $this->assertEqual([is_dir("$made/page/Andrew"),
            is_dir("$made/thumbs/Andrew"),
            is_dir(L\ResourceFolders::groupPath("Group 100", $resources))],
            [true, true, true], "a folder a person made, its thumbnail " .
            "folder and the group holding them stay");
        $this->db->unlinkRecursive($resources, true);
    }
    /**
     * pageFoldersAreNamedByNamesTestCase checks that the folders
     * getGroupPageResourcesFolders gives for a wiki page and for a post
     * are those ResourceFolders names from the group's name, the page's
     * name and locale, and the post's author and time, so they hold no id
     * and no AUTH_KEY and are the same on another instance holding the
     * same group.
     */
    public function pageFoldersAreNamedByNamesTestCase()
    {
        $this->addUsersAndPublicGroup();
        $this->db->execute("UPDATE GROUP_ITEM SET PUBDATE = 1700000000 " .
            "WHERE ID = 2");
        $page = $this->group_model->getGroupPageResourcesFolders(100, 1, "",
            false, false, false);
        $post = $this->group_model->getGroupPageResourcesFolders(100,
            "post2", "", false, false, false);
        $page_path = L\ResourceFolders::wikiPagePath("Group 100", "en-US",
            "Page 1", $this->where . "/resources");
        $post_path = L\ResourceFolders::postPath("Group 100", "writer",
            1700000000, $this->where . "/resources");
        $this->assertEqual([$page[0] ?? "", $page[1] ?? ""],
            ["$page_path/page", "$page_path/thumbs"],
            "a wiki page's folders are named by group, locale and page");
        $this->assertEqual($post[0] ?? "", "$post_path/page",
            "a post's folder is named by group, author and time");
        $this->assertEqual(str_contains($post_path, "/2023-11/"), true,
            "and sits in the folder of its month");
    }
    /**
     * deleteGroupDeletesItsWholeFolderTestCase checks that deleteGroup
     * deletes the group's folder under app/resources with all it holds,
     * its posts' folders as well as its pages', so a group made later
     * under the same name starts with no files.
     */
    public function deleteGroupDeletesItsWholeFolderTestCase()
    {
        $name = "Delete Test " . uniqid();
        $this->db->execute("INSERT INTO SOCIAL_GROUPS (GROUP_ID, " .
            "GROUP_NAME, OWNER_ID) VALUES (?, ?, ?)", [400, $name, 5]);
        $post = L\ResourceFolders::postPath($name, "writer", 5,
            $this->where . "/resources");
        mkdir("$post/page", 0777, true);
        file_put_contents("$post/page/file.txt", "resource");
        $this->group_model->deleteGroup(400);
        $group_path = L\ResourceFolders::groupPath($name,
            $this->where . "/resources");
        $kept = is_dir($group_path);
        if ($kept) {
            $this->db->unlinkRecursive($group_path, true);
        }
        $this->assertEqual($kept, false,
            "the group's folder, with its posts' files, is deleted");
    }
    /**
     * personalGroupIsNamedByUserNameTestCase checks that a user's
     * personal group is named by the user's name, lowercased, and
     * PERSONAL_GROUP_SUFFIX, and that getPersonalGroupId finds it by that
     * name.
     */
    public function personalGroupIsNamedByUserNameTestCase()
    {
        $this->addUsersAndPublicGroup();
        $this->db->execute("INSERT INTO SOCIAL_GROUPS (GROUP_ID, " .
            "GROUP_NAME, OWNER_ID) VALUES (?, ?, ?)",
            [61, "writer" . C\PERSONAL_GROUP_SUFFIX, 5]);
        $this->assertEqual([$this->group_model->personalGroupName(5),
            $this->group_model->getPersonalGroupId(5),
            $this->group_model->getPersonalGroupId(77)],
            ["writer\$Personal", 61, -1],
            "the personal group is found by the user's name");
    }
    /**
     * addUsersAndPublicGroup adds to the throwaway database a USERS table
     * holding user 5, named Writer, and the Public group, and gives the
     * wiki pages setUp made the locale en-US.
     */
    private function addUsersAndPublicGroup()
    {
        $profile = new ProfileModel(C\DB_NAME, false);
        $profile->initializeSql($this->db, ["DBMS" => "Sqlite3",
            "DB_HOST" => ""]);
        $this->db->execute($profile->create_statements['USERS']);
        $this->db->execute("INSERT INTO USERS (USER_ID, USER_NAME) " .
            "VALUES (5, 'Writer')");
        $this->db->execute("INSERT INTO SOCIAL_GROUPS (GROUP_ID, " .
            "GROUP_NAME, OWNER_ID) VALUES (?, ?, ?)",
            [C\PUBLIC_GROUP_ID, C\PUBLIC_GROUP_NAME, 1]);
        $this->db->execute("UPDATE GROUP_PAGE SET LOCALE_TAG = 'en-US'");
    }
    /**
     * makePageFolder makes the page and thumbs folders of a page or post
     * folder under a test resources folder, with a file.txt in the page
     * folder and its thumbnail file.txt.webp in the thumbs folder.
     *
     * @param string $path the page's or post's folder
     * @return string the same folder path, so a case can make the folder
     *      and name it in one step
     */
    private function makePageFolder($path)
    {
        mkdir("$path/page", 0777, true);
        mkdir("$path/thumbs", 0777, true);
        file_put_contents("$path/page/file.txt", "resource");
        file_put_contents("$path/thumbs/file.txt.webp", "thumbnail");
        return $path;
    }
    /**
     * groupThatCannotBeStoredGivesMinusOneTestCase asks addGroup for a
     * group the database cannot store, here because the groups table is
     * not under the name addGroup writes to, as in a database from before
     * upgrade 70 renamed GROUPS to SOCIAL_GROUPS. addGroup gives -1,
     * raises no warning, and writes no membership for a group that is not
     * there.
     */
    public function groupThatCannotBeStoredGivesMinusOneTestCase()
    {
        $this->db->execute("ALTER TABLE SOCIAL_GROUPS RENAME TO GROUPS_AWAY");
        $warnings = 0;
        set_error_handler(function () use (&$warnings) {
            $warnings++;
            return true;
        });
        ob_start();
        try {
            $group_id = $this->group_model->addGroup("Cannot Store", 5);
        } catch (\Throwable $e) {
            $group_id = "threw";
        }
        ob_end_clean();
        restore_error_handler();
        $this->db->execute("ALTER TABLE GROUPS_AWAY RENAME TO SOCIAL_GROUPS");
        $result = $this->db->execute("SELECT COUNT(*) AS NUM FROM " .
            "USER_GROUP WHERE USER_ID = 5");
        $memberships = (int)($this->db->fetchArray($result)['NUM'] ?? -1);
        /* user 5 belongs to group 100 only, as setUp makes it */
        $this->assertEqual([$group_id, $warnings, $memberships], [-1, 0, 1],
            "addGroup gives -1, warns of nothing and adds no membership");
    }
    /**
     * badgeCountsLeaveOutWikiPagePostsTestCase checks the counts the
     * discussion badges show. Group 101 holds the post Yioop writes when a
     * wiki page is made, which starts its own thread and has the type
     * WIKI_GROUP_ITEM, a comment a member left on that page's thread, and
     * a thread a member started. The badge counts, getUserPostCount and
     * getGroupPostCount asked for written posts only, give 2: the post
     * Yioop wrote is left out. getGroupPostCount asked for every post
     * still gives 3.
     */
    public function badgeCountsLeaveOutWikiPagePostsTestCase()
    {
        $this->group_model->addUserGroup(6, 101);
        foreach ([[50, 50, C\WIKI_GROUP_ITEM], [51, 50, C\STANDARD_GROUP_ITEM],
            [52, 52, C\STANDARD_GROUP_ITEM]] as list($id, $parent_id, $type)) {
            $this->db->execute("INSERT INTO GROUP_ITEM (ID, PARENT_ID, " .
                "GROUP_ID, USER_ID, TITLE, DESCRIPTION, PUBDATE, TYPE) " .
                "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", [$id, $parent_id, 101, 5,
                "Post", "body", self::POST_TIME, $type]);
        }
        $this->assertEqual([$this->feed_model->getUserPostCount(6),
            $this->feed_model->getGroupPostCount(101, 0, true),
            $this->feed_model->getGroupPostCount(101)], [2, 2, 3],
            "the post Yioop wrote for the wiki page is not on a badge");
    }
    /**
     * threadListChoicesTestCase checks the threads getGroupItems lists, and
     * getGroupItemCount counts, for the bar above a group's feed. Group 101
     * holds a wiki page's thread, started by Yioop and with one comment, a
     * thread a member started, and a git issue page's thread. By default
     * the wiki page's thread is left out; asked for all discussions it is
     * listed. Oldest first reverses the order, and a search keeps only the
     * thread whose post holds the words. The git issue thread is never
     * listed or counted.
     */
    public function threadListChoicesTestCase()
    {
        $profile = new ProfileModel(C\DB_NAME, false);
        $profile->initializeSql($this->db, ["DBMS" => "Sqlite3",
            "DB_HOST" => ""]);
        $this->db->execute($profile->create_statements['USERS']);
        $this->db->execute("INSERT INTO USERS (USER_ID, USER_NAME) " .
            "VALUES (5, 'owner')");
        $this->group_model->addUserGroup(5, 101);
        foreach ([[60, 60, C\WIKI_GROUP_ITEM, "Page made", 1000],
            [61, 60, C\STANDARD_GROUP_ITEM, "A comment", 1100],
            [62, 62, C\STANDARD_GROUP_ITEM, "Lunch plans", 1200],
            [63, 63, C\STANDARD_GROUP_ITEM, "An issue", 1300]] as
            list($id, $parent_id, $type, $title, $time)) {
            $this->db->execute("INSERT INTO GROUP_ITEM (ID, PARENT_ID, " .
                "GROUP_ID, USER_ID, TITLE, DESCRIPTION, PUBDATE, TYPE) " .
                "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", [$id, $parent_id, 101, 5,
                $title, "body", $time, $type]);
        }
        $this->db->execute("INSERT INTO GROUP_PAGE (ID, GROUP_ID, TITLE, " .
            "DISCUSS_THREAD) VALUES (?, ?, ?, ?)", [70, 101,
            "repo" . C\GIT_ISSUE_SEPARATOR . "1", 63]);
        $list = function ($choices) {
            $search_array = array_merge([["group_id", "=", 101, ""]],
                $choices);
            $threads = array_column($this->feed_model->getGroupItems(0, 10,
                $search_array, 5, 101), "PARENT_ID");
            return [$threads, intval($this->feed_model->getGroupItemCount(
                $search_array, 5, 101))];
        };
        $this->assertEqual($list([]), [[62, 60], 2],
            "with no choices, the threads people posted in, as before");
        $this->assertEqual($list([["wiki_threads", "=", "leave_out", ""]]),
            [[62], 1], "the thread people started, not the wiki page's");
        $this->assertEqual($list([["wiki_threads", "=", "all", ""]]),
            [[62, 60], 2], "all discussions, newest first");
        $this->assertEqual($list([["wiki_threads", "=", "all", ""],
            ["thread_order", "=", "", "ASC"]]), [[60, 62], 2],
            "all discussions, oldest first");
        $this->assertEqual($list([["wiki_threads", "=", "all", ""],
            ["text", "CONTAINS", "lunch", ""]]), [[62], 1],
            "a search keeps the thread holding the words");
    }
    /**
     * addNewPagesStoresWhatSetPageNameDoesTestCase adds the same pages to
     * two groups with no pages, one at a time with setPageName and all
     * together with addNewPages, and checks both groups hold the same
     * pages, histories, discussion threads, links and links to pages not
     * made yet. Links go to a page made before, to one made after, which
     * setPageName stores first as a link not made yet, and to one never
     * made. One page is named twice, so it is saved again, and the second
     * revision, saved in the same second, takes the first's place.
     * addNewPages records no impressions, where setPageName
     * records the page and its thread as viewed.
     */
    public function addNewPagesStoresWhatSetPageNameDoesTestCase()
    {
        $profile = new ProfileModel(C\DB_NAME, false);
        $profile->initializeSql($this->db, ["DBMS" => "Sqlite3",
            "DB_HOST" => ""]);
        foreach (['GROUP_PAGE_HISTORY', 'GROUP_PAGE_LINK',
            'GROUP_PAGE_PRE_LINK', 'PAGE_RELATIONSHIP'] as $table) {
            $this->db->execute($profile->create_statements[$table]);
        }
        foreach ([self::ONE_AT_A_TIME_GROUP, self::TOGETHER_GROUP] as
            $group_id) {
            $this->db->execute("INSERT INTO SOCIAL_GROUPS (GROUP_ID, " .
                "GROUP_NAME, OWNER_ID, RENDER_ENGINE) VALUES (?, ?, ?, ?)",
                [$group_id, "Group $group_id", 5, C\MEDIAWIKI_ENGINE]);
        }
        $pages = [["Main", "Go to [[Later]], [[Gone]] and " .
            "[[see also|Main|myself]].", "en-US"],
            ["Later", "Back to [[Main]].", "en-US"],
            ["Main", "Hauptseite, siehe [[Later]].", "de"],
            ["template:Box", "A box.", "en-US"],
            ["Later", "The second revision of [[Main]].", "en-US"]];
        $info = fn($group_id) => array_map(fn($page) => [$group_id,
            $page[0], $page[1], $page[2], "create", "Made " . $page[0],
            "Discuss here"], $pages);
        foreach ($info(self::ONE_AT_A_TIME_GROUP) as $page) {
            $this->wiki_model->setPageName(5, ...$page);
        }
        $this->assertTrue($this->wiki_model->addNewPages(5,
            $info(self::TOGETHER_GROUP)), "the pages are added together");
        $stored = function ($group_id) {
            $rows = function ($sql) use ($group_id) {
                $result = $this->db->execute($sql, [$group_id]);
                $all = [];
                while ($row = $this->db->fetchArray($result)) {
                    $all[] = $row;
                }
                return $all;
            };
            return [
                $rows("SELECT P.TITLE, P.LOCALE_TAG, P.PAGE, I.TITLE AS " .
                    "THREAD, I.TYPE, I.PARENT_ID - I.ID AS STARTS FROM " .
                    "GROUP_PAGE P, GROUP_ITEM I WHERE P.GROUP_ID = ? AND " .
                    "I.ID = P.DISCUSS_THREAD ORDER BY P.ID"),
                $rows("SELECT P.TITLE, P.LOCALE_TAG, H.PAGE, H.EDITOR_ID, " .
                    "H.EDIT_COMMENT FROM GROUP_PAGE_HISTORY H, GROUP_PAGE P " .
                    "WHERE H.GROUP_ID = ? AND P.ID = H.PAGE_ID ORDER BY " .
                    "P.ID, H.PUBDATE"),
                $rows("SELECT L.LINK_TYPE_ID, F.TITLE AS FROM_TITLE, " .
                    "F.LOCALE_TAG AS FROM_LOCALE, T.TITLE AS TO_TITLE, " .
                    "T.LOCALE_TAG AS TO_LOCALE FROM GROUP_PAGE_LINK L, " .
                    "GROUP_PAGE F, GROUP_PAGE T WHERE F.GROUP_ID = ? AND " .
                    "F.ID = L.FROM_ID AND T.ID = L.TO_ID ORDER BY " .
                    "L.LINK_TYPE_ID, F.TITLE, F.LOCALE_TAG, T.TITLE"),
                $rows("SELECT L.LINK_TYPE_ID, F.TITLE AS FROM_TITLE, " .
                    "L.TO_PAGE_NAME FROM GROUP_PAGE_PRE_LINK L, " .
                    "GROUP_PAGE F WHERE L.TO_GROUP_ID = ? AND " .
                    "F.ID = L.FROM_ID ORDER BY F.TITLE, L.TO_PAGE_NAME"),
                $rows("SELECT COUNT(*) AS NUM FROM GROUP_PAGE_LINK L, " .
                    "GROUP_PAGE P WHERE L.LINK_TYPE_ID = " .
                    C\WIKI_TEMPLATE_LINK . " AND L.FROM_ID = P.ID AND " .
                    "L.TO_ID = P.GROUP_ID AND P.GROUP_ID = ?")];
        };
        /* setPageName keeps a page's two revisions apart when a second
           passes between them, and the second replaces the first when
           none does, so the histories are compared by each page's last
           revision. */
        $last_revisions = function ($found) {
            $last = [];
            foreach ($found[1] as $row) {
                $last[$row['TITLE'] . " " . $row['LOCALE_TAG']] = $row;
            }
            $found[1] = array_values($last);
            return $found;
        };
        $one_at_a_time = $last_revisions(
            $stored(self::ONE_AT_A_TIME_GROUP));
        $together = $last_revisions($stored(self::TOGETHER_GROUP));
        $this->assertEqual(count($together[0]), 4, "four pages are made");
        $later = array_values(array_filter($together[1], fn($row) =>
            $row['TITLE'] == "Later"));
        $this->assertTrue(count($later) == 1 && str_contains(
            $later[0]['PAGE'], "second revision"), "a page named twice " .
            "keeps the second revision, which in the same second as the " .
            "first takes its place");
        $this->assertTrue(count($together[2]) >= 3 &&
            count($together[3]) == 1, "the links are stored");
        $names = ["pages and threads", "histories", "links",
            "links to pages not made yet", "template links"];
        foreach ($names as $i => $name) {
            $this->assertEqual($together[$i], $one_at_a_time[$i],
                "the $name are the same as setPageName's");
        }
        $impressions = function ($group_id) {
            $result = $this->db->execute("SELECT COUNT(*) AS NUM FROM " .
                "ITEM_IMPRESSION_SUMMARY S, GROUP_PAGE P WHERE " .
                "P.GROUP_ID = ? AND S.ITEM_ID = P.ID AND S.ITEM_TYPE = " .
                C\WIKI_IMPRESSION, [$group_id]);
            return intval($this->db->fetchArray($result)['NUM']);
        };
        $this->assertTrue($impressions(self::ONE_AT_A_TIME_GROUP) > 0,
            "setPageName records the pages as viewed");
        $this->assertEqual($impressions(self::TOGETHER_GROUP), 0,
            "addNewPages records no impressions");
    }
    /**
     * firstViewStartsTheCountTestCase checks that a page with no count of
     * its views yet, as createdb now makes the Public and Help pages, is
     * counted from its first real view on. Before, a view only added to a
     * count initWithDb had started, so such a page stayed at no views.
     */
    public function firstViewStartsTheCountTestCase()
    {
        $views = function () {
            $result = $this->db->execute("SELECT NUM_VIEWS FROM " .
                "ITEM_IMPRESSION_SUMMARY WHERE USER_ID = ? AND ITEM_ID = ? " .
                "AND ITEM_TYPE = ? AND UPDATE_PERIOD = ? AND " .
                "UPDATE_TIMESTAMP = 0", [C\PUBLIC_USER_ID,
                self::UNCOUNTED_PAGE, C\WIKI_IMPRESSION, C\FOREVER]);
            $row = $this->db->fetchArray($result);
            return ($row) ? intval($row['NUM_VIEWS']) : null;
        };
        $this->assertEqual($views(), null, "the page has no count yet");
        ImpressionModel::addWithDb(7, self::UNCOUNTED_PAGE,
            C\WIKI_IMPRESSION, $this->db);
        $this->assertEqual($views(), 1, "the first view starts the count");
        ImpressionModel::addWithDb(6, self::UNCOUNTED_PAGE,
            C\WIKI_IMPRESSION, $this->db);
        $this->assertEqual($views(), 2, "a later view adds to it");
    }
    /**
     * UNCOUNTED_PAGE is the id of the page firstViewStartsTheCountTestCase
     * views, which has no count of its views to begin with.
     */
    const UNCOUNTED_PAGE = 900;
    /**
     * ONE_AT_A_TIME_GROUP is the group addNewPagesStoresWhatSetPageNameDoes
     * TestCase adds pages to with setPageName.
     */
    const ONE_AT_A_TIME_GROUP = 200;
    /**
     * TOGETHER_GROUP is the group addNewPagesStoresWhatSetPageNameDoes
     * TestCase adds pages to with addNewPages.
     */
    const TOGETHER_GROUP = 201;
    /**
     * POST_TIME is the time badgeCountsLeaveOutWikiPagePostsTestCase gives
     * its posts, after the time a badge counts from
     */
    const POST_TIME = 1000;
}