/ 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\UserModel;
use seekquarry\yioop\models\WikiModel;
use seekquarry\yioop\models\Model;
use seekquarry\yioop\models\ProfileModel;
use seekquarry\yioop\models\datasources\Sqlite3Manager;

/**
 * Checks what GroupModel counts and what it lets go of. The counting
 * cases stand up groups, members, wiki pages, threads and posts. The
 * letting-go cases stand up one conversation, kept twice as a message
 * between two people is, and ask who may let go of which copy.
 *
 * @author Chris Pollett
 */
class GroupModelTest extends UnitTest
{
    /**
     * Folder under the test files the throwaway database is built in.
     */
    const TEST_DIR = '/test_files/group_model_message_delete';
    /**
     * The group a message's writer keeps their own copy in.
     */
    const WRITER_GROUP = 60;
    /**
     * The group the person written to keeps their copy in.
     */
    const READER_GROUP = 70;
    /**
     * The thread both copies of a conversation hang from.
     */
    const THREAD = 500;
    /**
     * Who wrote the messages.
     */
    const WRITER = 3;
    /**
     * Who was written to.
     */
    const READER = 4;
    /**
     * A throwaway Sqlite3 database built from ProfileModel definitions.
     * @var object
     */
    public $db;
    /**
     * File path of the throwaway database.
     * @var string
     */
    public $db_path;
    /**
     * Folder the throwaway database sits in.
     * @var string
     */
    public $where;
    /**
     * GroupModel wired to the throwaway database.
     * @var object
     */
    public $group_model;
    /**
     * $wiki_model is the model the cases read wiki pages through
     * @var WikiModel
     */
    public $wiki_model;
    /**
     * $feed_model is the model the cases read the feed through
     * @var FeedModel
     */
    public $feed_model;
    /**
     * Builds a throwaway database and stands up both 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 lets go of
     * cannot be missing from 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);
        $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]);
        }
        $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;
        /* 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);
    }
    /**
     * Puts one message row in the throwaway database.
     *
     * @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 thread it hangs from
     * @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]);
    }
    /**
     * Says how many rows are left in a given group.
     *
     * @param int $group_id whose group to count
     * @return int how many rows it 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']);
    }
    /**
     * Takes down the throwaway database and the folder it sat 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);
        }
    }
    /**
     * The person written to can let go of their own copy of a message
     * someone else wrote, which is what lets a message stay gone from
     * one list while the other 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");
    }
    /**
     * The writer can let go of their own copy, and doing so leaves the
     * copy the other person holds alone.
     */
    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");
    }
    /**
     * A message in neither one's own group nor of one's own writing is
     * not theirs to let go of.
     */
    public function anOutsiderMayNotLetGoTestCase()
    {
        $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");
    }
    /**
     * Letting go of a whole conversation takes every message one person
     * holds in it, keeps the thread they hang from, and leaves the
     * other person's copies untouched.
     */
    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");
    }
    /**
     * Taking a conversation off one person's list lets go of the row it
     * hung from in their group and leaves the other person's row where
     * it was, so the conversation stays on the other'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");
    }
    /**
     * Owned-group counts reflect each owner's rows 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");
    }
    /**
     * Member count reflects the seeded memberships of a group.
     */
    public function countGroupUsersTestCase()
    {
        $this->assertEqual(3,
            (int)$this->group_model->countGroupUsers(100),
            "group 100 has three members");
    }
    /**
     * Wiki-page count reflects the pages belonging to a group.
     */
    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");
    }
    /**
     * Thread count reflects only the top-level items of a group.
     */
    public function countGroupThreadsTestCase()
    {
        $this->assertEqual(2,
            (int)$this->feed_model->countGroupThreads(100),
            "group 100 has two threads, not counting replies");
    }
    /**
     * Checks that a file asked for as a thumb is drawn as the small
     * picture kept beside it, whatever the file is, so a document shows
     * its front page rather than opening in a frame of its own. The
     * picture lives at the file's own address under another word.
     */
    public function thumbOfDocumentDrawsItsPictureTestCase()
    {
        $model = new WikiModel();
        $folders = $model->getGroupPageResourcesFolders(2, 1, "", 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(2, 1, "en-US", $page,
            "", "group");
        unlink($beside);
        $bare = $model->insertResourcesParsePage(2, 1, "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();
        $folders = $model->getGroupPageResourcesFolders(2, 1, "", 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();
        $folders = $model->getGroupPageResourcesFolders(2, 1,
            "Andrew", true);
        $webp = imagecreatetruecolor(20, 20);
        imagewebp($webp, $folders[0] . "/shot.webp");
        $model->makeThumbStripExif("shot.webp", $folders[0],
            $folders[1]);
        $listing = $model->getGroupPageResourceUrls(2, 1, "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");
    }
    /**
     * Post count reflects a thread's starting item and 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");
    }
}
X