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