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