MDL-46881 mod_forum: Move cron around
authorAndrew Nicols <andrew@nicols.co.uk>
Fri, 1 Jun 2018 03:39:39 +0000 (11:39 +0800)
committerAndrew Nicols <andrew@nicols.co.uk>
Tue, 29 Jan 2019 23:48:38 +0000 (07:48 +0800)
12 files changed:
mod/forum/classes/task/cron_task.php
mod/forum/classes/task/send_user_digests.php [new file with mode: 0644]
mod/forum/classes/task/send_user_notifications.php [new file with mode: 0644]
mod/forum/deprecatedlib.php
mod/forum/lib.php
mod/forum/templates/forum_post_emaildigestfull_textemail.mustache
mod/forum/tests/cron_trait.php [new file with mode: 0644]
mod/forum/tests/generator_trait.php [new file with mode: 0644]
mod/forum/tests/mail_group_test.php [new file with mode: 0644]
mod/forum/tests/mail_test.php
mod/forum/tests/maildigest_test.php
mod/forum/tests/qanda_test.php [new file with mode: 0644]

index f232c62..ee2d296 100644 (file)
 /**
  * A scheduled task for forum cron.
  *
- * @todo MDL-44734 This job will be split up properly.
- *
  * @package    mod_forum
  * @copyright  2014 Dan Poltawski <dan@moodle.com>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 namespace mod_forum\task;
 
+defined('MOODLE_INTERNAL') || die();
+
+require_once($CFG->dirroot . '/mod/forum/lib.php');
+
+/**
+ * The main scheduled task for the forum.
+ *
+ * @package    mod_forum
+ * @copyright  2018 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
 class cron_task extends \core\task\scheduled_task {
 
+    // Use the logging trait to get some nice, juicy, logging.
+    use \core\task\logging_trait;
+
+    /**
+     * @var The list of courses which contain posts to be sent.
+     */
+    protected $courses = [];
+
+    /**
+     * @var The list of forums which contain posts to be sent.
+     */
+    protected $forums = [];
+
+    /**
+     * @var The list of discussions which contain posts to be sent.
+     */
+    protected $discussions = [];
+
+    /**
+     * @var The list of posts to be sent.
+     */
+    protected $posts = [];
+
+    /**
+     * @var The list of post authors.
+     */
+    protected $users = [];
+
+    /**
+     * @var The list of subscribed users.
+     */
+    protected $subscribedusers = [];
+
+    /**
+     * @var The list of digest users.
+     */
+    protected $digestusers = [];
+
+    /**
+     * @var The list of adhoc data for sending.
+     */
+    protected $adhocdata = [];
+
     /**
      * Get a descriptive name for this task (shown to admins).
      *
@@ -37,12 +89,512 @@ class cron_task extends \core\task\scheduled_task {
     }
 
     /**
-     * Run forum cron.
+     * Execute the scheduled task.
      */
     public function execute() {
-        global $CFG;
-        require_once($CFG->dirroot . '/mod/forum/lib.php');
-        forum_cron();
+        global $CFG, $DB;
+
+        $timenow = time();
+
+        // Delete any really old posts in the digest queue.
+        $weekago = $timenow - (7 * 24 * 3600);
+        $this->log_start("Removing old digest records from 7 days ago.");
+        $DB->delete_records_select('forum_queue', "timemodified < ?", array($weekago));
+        $this->log_finish("Removed all old digest records.");
+
+        $endtime   = $timenow - $CFG->maxeditingtime;
+        $starttime = $endtime - (2 * DAYSECS);
+        $this->log_start("Fetching unmailed posts.");
+        if (!$posts = $this->get_unmailed_posts($starttime, $endtime, $timenow)) {
+            $this->log_finish("No posts found.", 1);
+            return false;
+        }
+        $this->log_finish("Done");
+
+        // Process post data and turn into adhoc tasks.
+        $this->process_post_data($posts);
+
+        // Mark posts as read.
+        list($in, $params) = $DB->get_in_or_equal(array_keys($posts));
+        $DB->set_field_select('forum_posts', 'mailed', 1, "id {$in}", $params);
+    }
+
+    /**
+     * Process all posts and convert to appropriated hoc tasks.
+     *
+     * @param   \stdClass[] $posts
+     */
+    protected function process_post_data($posts) {
+        $trace = $this->get_trace();
+
+        $discussionids = [];
+        $forumids = [];
+        $courseids = [];
+
+        $this->log_start("Processing post information");
+
+        $start = microtime(true);
+        foreach ($posts as $id => $post) {
+            $discussionids[$post->discussion] = true;
+            $forumids[$post->forum] = true;
+            $courseids[$post->course] = true;
+            $this->add_data_for_post($post);
+            $this->posts[$id] = $post;
+        }
+        $this->log_finish(sprintf("Processed %s posts", count($this->posts)));
+
+        if (empty($this->posts)) {
+            $this->log("No posts found. Returning early.");
+            return;
+        }
+
+        // Please note, this order is intentional.
+        // The forum cache makes use of the course.
+        $this->log_start("Filling caches");
+
+        $start = microtime(true);
+        $this->log_start("Filling course cache", 1);
+        $this->fill_course_cache(array_keys($courseids));
+        $this->log_finish("Done", 1);
+
+        $this->log_start("Filling forum cache", 1);
+        $this->fill_forum_cache(array_keys($forumids));
+        $this->log_finish("Done", 1);
+
+        $this->log_start("Filling discussion cache", 1);
+        $this->fill_discussion_cache(array_keys($discussionids));
+        $this->log_finish("Done", 1);
+
+        $this->log_start("Filling user subscription cache", 1);
+        $this->fill_user_subscription_cache();
+        $this->log_finish("Done", 1);
+
+        $this->log_start("Filling digest cache", 1);
+        $this->fill_digest_cache();
+        $this->log_finish("Done", 1);
+
+        $this->log_start("Filling user posted cache for Q&A forums", 1);
+        $this->fetch_user_has_posted();
+        $this->log_finish("Done", 1);
+
+        $this->log_start("Queueing user tasks.");
+        $this->queue_user_tasks();
+        $this->log_finish("All tasks queued.", 1);
+
+        $this->log_finish("All caches filled");
+    }
+
+    /**
+     * Fill the course cache.
+     *
+     * @param   int[]       $courseids
+     */
+    protected function fill_course_cache($courseids) {
+        global $DB;
+
+        list($in, $params) = $DB->get_in_or_equal($courseids);
+        $this->courses = $DB->get_records_select('course', "id $in", $params);
+    }
+
+    /**
+     * Fill the forum cache.
+     *
+     * @param   int[]       $forumids
+     */
+    protected function fill_forum_cache($forumids) {
+        global $DB;
+
+        $requiredfields = [
+                'id',
+                'course',
+                'forcesubscribe',
+                'type',
+            ];
+        list($in, $params) = $DB->get_in_or_equal($forumids);
+        $this->forums = $DB->get_records_select('forum', "id $in", $params, '', implode(', ', $requiredfields));
+        foreach ($this->forums as $id => $forum) {
+            \mod_forum\subscriptions::fill_subscription_cache($id);
+            \mod_forum\subscriptions::fill_discussion_subscription_cache($id);
+        }
     }
 
+    /**
+     * Fill the discussion cache.
+     *
+     * @param   int[]       $discussionids
+     */
+    protected function fill_discussion_cache($discussionids) {
+        global $DB;
+
+        if (empty($discussionids)) {
+            $this->discussion = [];
+        } else {
+
+            $requiredfields = [
+                    'id',
+                    'groupid',
+                    'firstpost',
+                    'timestart',
+                    'timeend',
+                ];
+
+            list($in, $params) = $DB->get_in_or_equal($discussionids);
+            $this->discussions = $DB->get_records_select(
+                    'forum_discussions', "id $in", $params, '', implode(', ', $requiredfields));
+        }
+    }
+
+    /**
+     * Fill the cache of user digest preferences.
+     */
+    protected function fill_digest_cache() {
+        global $DB;
+
+        if (empty($this->users)) {
+            return;
+        }
+        // Get the list of forum subscriptions for per-user per-forum maildigest settings.
+        list($in, $params) = $DB->get_in_or_equal(array_keys($this->users));
+        $digestspreferences = $DB->get_recordset_select(
+                'forum_digests', "userid $in", $params, '', 'id, userid, forum, maildigest');
+        foreach ($digestspreferences as $digestpreference) {
+            if (!isset($this->digestusers[$digestpreference->forum])) {
+                $this->digestusers[$digestpreference->forum] = [];
+            }
+            $this->digestusers[$digestpreference->forum][$digestpreference->userid] = $digestpreference->maildigest;
+        }
+        $digestspreferences->close();
+    }
+
+    /**
+     * Add dsta for the current forum post to the structure of adhoc data.
+     *
+     * @param   \stdClass   $post
+     */
+    protected function add_data_for_post($post) {
+        if (!isset($this->adhocdata[$post->course])) {
+            $this->adhocdata[$post->course] = [];
+        }
+
+        if (!isset($this->adhocdata[$post->course][$post->forum])) {
+            $this->adhocdata[$post->course][$post->forum] = [];
+        }
+
+        if (!isset($this->adhocdata[$post->course][$post->forum][$post->discussion])) {
+            $this->adhocdata[$post->course][$post->forum][$post->discussion] = [];
+        }
+
+        $this->adhocdata[$post->course][$post->forum][$post->discussion][$post->id] = $post->id;
+    }
+
+    /**
+     * Fill the cache of user subscriptions.
+     */
+    protected function fill_user_subscription_cache() {
+        foreach ($this->forums as $forum) {
+            $cm = get_fast_modinfo($this->courses[$forum->course])->instances['forum'][$forum->id];
+            $modcontext = \context_module::instance($cm->id);
+
+            $this->subscribedusers[$forum->id] = [];
+            if ($users = \mod_forum\subscriptions::fetch_subscribed_users($forum, 0, $modcontext, 'u.id, u.maildigest', true)) {
+                foreach ($users as $user) {
+                    // This user is subscribed to this forum.
+                    $this->subscribedusers[$forum->id][$user->id] = $user->id;
+                    if (!isset($this->users[$user->id])) {
+                        // Store minimal user info.
+                        $this->users[$user->id] = $user;
+                    }
+                }
+                // Release memory.
+                unset($users);
+            }
+        }
+    }
+
+    /**
+     * Fill the cache for Q&A forums.
+     */
+    protected function fetch_user_has_posted() {
+        global $DB;
+
+        $forums = array_filter($this->forums, function($forum) {
+            return $forum->type === 'qanda';
+        });
+
+        if (empty($forums)) {
+            return;
+        }
+
+        list($in, $params) = $DB->get_in_or_equal(array_keys($forums));
+
+        $sql = "SELECT d.forum, d.firstpost, p.userid
+                 FROM {forum} f
+           INNER JOIN {forum_discussions} d ON d.forum = f.id
+            LEFT JOIN {forum_posts} p ON p.discussion = d.id
+                WHERE f.type = 'qanda'
+                  AND f.id {$in}
+             GROUP BY p.userid, d.forum, d.firstpost";
+
+        $rs = $DB->get_recordset_sql($sql, $params);
+        foreach ($rs as $row) {
+            if (empty($this->qandametadata[$row->forum])) {
+                $this->qandametadata[$row->forum] = (object) [
+                        'users' => [],
+                        'firstpost' => [$row->firstpost],
+                    ];
+            }
+            $this->qandametadata[$row->forum]->users[$row->userid] = true;
+        }
+        $rs->close();
+
+    }
+
+    /**
+     * Queue the user tasks.
+     */
+    protected function queue_user_tasks() {
+        global $CFG, $DB;
+
+        $trace = $this->get_trace();
+
+        $timenow = time();
+        $sitetimezone = \core_date::get_server_timezone();
+        $counts = [
+            'digests' => 0,
+            'individuals' => 0,
+            'users' => 0,
+            'ignored' => 0,
+            'messages' => 0,
+        ];
+        $this->log("Processing " . count($this->users) . " users", 1);
+        foreach ($this->users as $user) {
+            $usercounts = [
+                'digests' => 0,
+                'messages' => 0,
+            ];
+
+            $send = false;
+            // Setup this user so that the capabilities are cached, and environment matches receiving user.
+            cron_setup_user($user);
+
+            list($individualpostdata, $digestpostdata) = $this->fetch_posts_for_user($user);
+
+            if (!empty($digestpostdata)) {
+                // Insert all of the records for the digest.
+                $DB->insert_records('forum_queue', $digestpostdata);
+                $digesttime = usergetmidnight($timenow, $sitetimezone) + ($CFG->digestmailtime * 3600);
+
+                $task = new \mod_forum\task\send_user_digests();
+                $task->set_userid($user->id);
+                $task->set_component('mod_forum');
+                $task->set_next_run_time($digesttime);
+                \core\task\manager::queue_adhoc_task($task, true);
+                $usercounts['digests']++;
+                $send = true;
+            }
+
+            if (!empty($individualpostdata)) {
+                $usercounts['messages'] += count($individualpostdata);
+
+                $task = new \mod_forum\task\send_user_notifications();
+                $task->set_userid($user->id);
+                $task->set_custom_data($individualpostdata);
+                $task->set_component('mod_forum');
+                \core\task\manager::queue_adhoc_task($task);
+                $counts['individuals']++;
+                $send = true;
+            }
+
+            if ($send) {
+                $counts['users']++;
+                $counts['messages'] += $usercounts['messages'];
+                $counts['digests'] += $usercounts['digests'];
+            } else {
+                $counts['ignored']++;
+            }
+
+            $this->log(sprintf("Queued %d digests and %d messages for %s",
+                    $usercounts['digests'],
+                    $usercounts['messages'],
+                    $user->id
+                ), 2);
+        }
+        $this->log(
+            sprintf(
+                "Queued %d digests, and %d individual tasks for %d post mails. " .
+                "Unique users: %d (%d ignored)",
+                $counts['digests'],
+                $counts['individuals'],
+                $counts['messages'],
+                $counts['users'],
+                $counts['ignored']
+            ), 1);
+    }
+
+    /**
+     * Fetch posts for this user.
+     *
+     * @param   \stdClass   $user The user to fetch posts for.
+     */
+    protected function fetch_posts_for_user($user) {
+        // We maintain a mapping of user groups for each forum.
+        $usergroups = [];
+        $digeststructure = [];
+
+        $poststructure = $this->adhocdata;
+        $poststosend = [];
+        foreach ($poststructure as $courseid => $forumids) {
+            $course = $this->courses[$courseid];
+            foreach ($forumids as $forumid => $discussionids) {
+                $forum = $this->forums[$forumid];
+                $maildigest = forum_get_user_maildigest_bulk($this->digestusers, $user, $forumid);
+
+                if (!isset($this->subscribedusers[$forumid][$user->id])) {
+                    // This user has no subscription of any kind to this forum.
+                    // Do not send them any posts at all.
+                    unset($poststructure[$courseid][$forumid]);
+                    continue;
+                }
+
+                $subscriptiontime = \mod_forum\subscriptions::fetch_discussion_subscription($forum->id, $user->id);
+
+                $cm = get_fast_modinfo($course)->instances['forum'][$forumid];
+                foreach ($discussionids as $discussionid => $postids) {
+                    $discussion = $this->discussions[$discussionid];
+                    if (!\mod_forum\subscriptions::is_subscribed($user->id, $forum, $discussionid, $cm)) {
+                        // The user does not subscribe to this forum as a whole, or to this specific discussion.
+                        unset($poststructure[$courseid][$forumid][$discussionid]);
+                        continue;
+                    }
+
+                    if ($discussion->groupid > 0 and $groupmode = groups_get_activity_groupmode($cm, $course)) {
+                        // This discussion has a groupmode set (SEPARATEGROUPS or VISIBLEGROUPS).
+                        // Check whether the user can view it based on their groups.
+                        if (!isset($usergroups[$forum->id])) {
+                            $usergroups[$forum->id] = groups_get_all_groups($courseid, $user->id, $cm->groupingid);
+                        }
+
+                        if (!isset($usergroups[$forum->id][$discussion->groupid])) {
+                            // This user is not a member of this group, or the group no longer exists.
+
+                            $modcontext = \context_module::instance($cm->id);
+                            if (!has_capability('moodle/site:accessallgroups', $modcontext, $user)) {
+                                // This user does not have the accessallgroups and is not a member of the group.
+                                // Do not send posts from other groups when in SEPARATEGROUPS or VISIBLEGROUPS.
+                                unset($poststructure[$courseid][$forumid][$discussionid]);
+                                continue;
+                            }
+                        }
+                    }
+
+                    foreach ($postids as $postid) {
+                        $post = $this->posts[$postid];
+                        if ($subscriptiontime) {
+                            // Skip posts if the user subscribed to the discussion after it was created.
+                            $subscribedafter = isset($subscriptiontime[$post->discussion]);
+                            $subscribedafter = $subscribedafter && ($subscriptiontime[$post->discussion] > $post->created);
+                            if ($subscribedafter) {
+                                // The user subscribed to the discussion/forum after this post was created.
+                                unset($poststructure[$courseid][$forumid][$discussionid]);
+                                continue;
+                            }
+                        }
+
+                        if ($forum->type === 'qanda' && $postid != $discussion->firstpost) {
+                            $hasqandapost = isset($this->qandametadata[$forumid]);
+                            $hasqandapost = $hasqandapost && isset($this->qandametadata[$forumid]->users[$user->id]);
+                            if (!$hasqandapost) {
+                                // The user has not posted to this qanda forum.
+                                unset($poststructure[$courseid][$forumid][$discussionid]);
+                                continue;
+                            }
+                        }
+
+                        if (!forum_user_can_see_post($forum, $discussion, $post, null, $cm)) {
+                            // The user is not allowed to see the post for some other reason.
+                            unset($poststructure[$courseid][$forumid][$discussionid][$postid]);
+                            continue;
+                        }
+
+                        if ($maildigest > 0) {
+                            // This user wants the mails to be in digest form.
+                            $digeststructure[] = (object) [
+                                'userid' => $user->id,
+                                'discussionid' => $discussion->id,
+                                'postid' => $post->id,
+                                'timemodified' => $post->created,
+                            ];
+                            unset($poststructure[$courseid][$forumid][$discussionid][$postid]);
+                            continue;
+                        } else {
+                            // Add this post to the list of postids to be sent.
+                            $poststosend[] = $postid;
+                        }
+                    }
+                }
+
+                if (empty($poststructure[$courseid][$forumid])) {
+                    // This user is not subscribed to any discussions in this forum at all.
+                    unset($poststructure[$courseid][$forumid]);
+                    continue;
+                }
+            }
+            if (empty($poststructure[$courseid])) {
+                // This user is not subscribed to any forums in this course.
+                unset($poststructure[$courseid]);
+            }
+        }
+
+        return [$poststosend, $digeststructure];
+    }
+
+    /**
+     * Returns a list of all new posts that have not been mailed yet
+     *
+     * @param int $starttime posts created after this time
+     * @param int $endtime posts created before this
+     * @param int $now used for timed discussions only
+     * @return array
+     */
+    protected function get_unmailed_posts($starttime, $endtime, $now = null) {
+        global $CFG, $DB;
+
+        $params = array();
+        $params['mailed'] = FORUM_MAILED_PENDING;
+        $params['ptimestart'] = $starttime;
+        $params['ptimeend'] = $endtime;
+        $params['mailnow'] = 1;
+
+        if (!empty($CFG->forum_enabletimedposts)) {
+            if (empty($now)) {
+                $now = time();
+            }
+            $selectsql = "AND (p.created >= :ptimestart OR d.timestart >= :pptimestart)";
+            $params['pptimestart'] = $starttime;
+            $timedsql = "AND (d.timestart < :dtimestart AND (d.timeend = 0 OR d.timeend > :dtimeend))";
+            $params['dtimestart'] = $now;
+            $params['dtimeend'] = $now;
+        } else {
+            $timedsql = "";
+            $selectsql = "AND p.created >= :ptimestart";
+        }
+
+        return $DB->get_records_sql(
+               "SELECT
+                    p.id,
+                    p.discussion,
+                    d.forum,
+                    d.course,
+                    p.created,
+                    p.parent,
+                    p.userid
+                  FROM {forum_posts} p
+                  JOIN {forum_discussions} d ON d.id = p.discussion
+                 WHERE p.mailed = :mailed
+                $selectsql
+                   AND (p.created < :ptimeend OR p.mailnow = :mailnow)
+                $timedsql
+                 ORDER BY p.modified ASC",
+             $params);
+    }
 }
diff --git a/mod/forum/classes/task/send_user_digests.php b/mod/forum/classes/task/send_user_digests.php
new file mode 100644 (file)
index 0000000..816ad76
--- /dev/null
@@ -0,0 +1,599 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle 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.
+//
+// Moodle 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 Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * This file defines an adhoc task to send notifications.
+ *
+ * @package    mod_forum
+ * @copyright  2018 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_forum\task;
+
+defined('MOODLE_INTERNAL') || die();
+
+use html_writer;
+require_once($CFG->dirroot . '/mod/forum/lib.php');
+
+/**
+ * Adhoc task to send moodle forum digests for the specified user.
+ *
+ * @package    mod_forum
+ * @copyright  2018 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class send_user_digests extends \core\task\adhoc_task {
+
+    // Use the logging trait to get some nice, juicy, logging.
+    use \core\task\logging_trait;
+
+    /**
+     * @var \stdClass   A shortcut to $USER.
+     */
+    protected $recipient;
+
+    /**
+     * @var bool[]  Whether the user can view fullnames for each forum.
+     */
+    protected $viewfullnames = [];
+
+    /**
+     * @var bool[]  Whether the user can post in each forum.
+     */
+    protected $canpostto = [];
+
+    /**
+     * @var \stdClass[] Courses with posts them.
+     */
+    protected $courses = [];
+
+    /**
+     * @var \stdClass[] Forums with posts them.
+     */
+    protected $forums = [];
+
+    /**
+     * @var \stdClass[] Discussions with posts them.
+     */
+    protected $discussions = [];
+
+    /**
+     * @var \stdClass[] The posts to be sent.
+     */
+    protected $posts = [];
+
+    /**
+     * @var \stdClass[] The various authors.
+     */
+    protected $users = [];
+
+    /**
+     * @var \stdClass[] A list of any per-forum digest preference that this user holds.
+     */
+    protected $forumdigesttypes = [];
+
+    /**
+     * @var bool    Whether the user has requested HTML or not.
+     */
+    protected $allowhtml = true;
+
+    /**
+     * @var string  The subject of the message.
+     */
+    protected $postsubject = '';
+
+    /**
+     * @var string  The plaintext content of the whole message.
+     */
+    protected $notificationtext = '';
+
+    /**
+     * @var string  The HTML content of the whole message.
+     */
+    protected $notificationhtml = '';
+
+    /**
+     * @var string  The plaintext content for the current discussion being processed.
+     */
+    protected $discussiontext = '';
+
+    /**
+     * @var string  The HTML content for the current discussion being processed.
+     */
+    protected $discussionhtml = '';
+
+    /**
+     * @var int     The number of messages sent in this digest.
+     */
+    protected $sentcount = 0;
+
+    /**
+     * @var \renderer[][] A cache of the different types of renderer, stored both by target (HTML, or Text), and type.
+     */
+    protected $renderers = [
+        'html' => [],
+        'text' => [],
+    ];
+
+    /**
+     * @var int[] A list of post IDs to be marked as read for this user.
+     */
+    protected $markpostsasread = [];
+
+    /**
+     * Send out messages.
+     */
+    public function execute() {
+        // Terminate if not able to fetch all digests in 5 minutes.
+        \core_php_time_limit::raise(300);
+
+        $starttime = time();
+
+        $this->recipient = \core_user::get_user($this->get_userid());
+        $this->log_start("Sending forum digests for {$this->recipient->username} ({$this->recipient->id})");
+
+        if (empty($this->recipient->mailformat) || $this->recipient->mailformat != 1) {
+            // This user does not want to receive HTML.
+            $this->allowhtml = false;
+        }
+
+        // Fetch all of the data we need to mail these posts.
+        $this->prepare_data($starttime);
+
+        if (empty($this->posts) || empty($this->discussions) || empty($this->forums)) {
+            $this->log_finish("No messages found to send.");
+            return;
+        }
+
+        // Add the message headers.
+        $this->add_message_header();
+
+        foreach ($this->discussions as $discussion) {
+            // Raise the time limit for each discussion.
+            \core_php_time_limit::raise(120);
+
+            // Grab the data pertaining to this discussion.
+            $forum = $this->forums[$discussion->forum];
+            $course = $this->courses[$forum->course];
+            $cm = get_fast_modinfo($course)->instances['forum'][$forum->id];
+            $modcontext = \context_module::instance($cm->id);
+            $coursecontext = \context_course::instance($course->id);
+
+            if (empty($this->posts[$discussion->id])) {
+                // Somehow there are no posts.
+                // This should not happen but better safe than sorry.
+                continue;
+            }
+
+            if (!$course->visible and !has_capability('moodle/course:viewhiddencourses', $coursecontext)) {
+                // The course is hidden and the user does not have access to it.
+                // Permissions may have changed since it was queued.
+                continue;
+            }
+
+            if (!forum_user_can_see_discussion($forum, $discussion, $modcontext, $this->recipient)) {
+                // User cannot see this discussion.
+                // Permissions may have changed since it was queued.
+                continue;
+            }
+
+            if (!\mod_forum\subscriptions::is_subscribed($this->recipient->id, $forum, $discussion->id, $cm)) {
+                // The user does not subscribe to this forum as a whole, or to this specific discussion.
+                continue;
+            }
+
+            // Fetch additional values relating to this forum.
+            if (!isset($this->canpostto[$discussion->id])) {
+                $this->canpostto[$discussion->id] = forum_user_can_post(
+                        $forum, $discussion, $this->recipient, $cm, $course, $modcontext);
+            }
+
+            if (!isset($this->viewfullnames[$forum->id])) {
+                $this->viewfullnames = has_capability('moodle/site:viewfullnames', $modcontext, $this->recipient->id);
+            }
+
+            // Set the discussion storage values.
+            $discussionpostcount = 0;
+            $this->discussiontext = '';
+            $this->discussionhtml = '';
+
+            // Add the header for this discussion.
+            $this->add_discussion_header($discussion, $forum, $course);
+            $this->log_start("Adding messages in discussion {$discussion->id} (forum {$forum->id})", 1);
+
+            // Add all posts in this forum.
+            foreach ($this->posts[$discussion->id] as $post) {
+                $author = $this->get_post_author($post->userid, $course, $forum, $cm, $modcontext);
+                if (empty($author)) {
+                    // Unable to find the author. Skip to avoid errors.
+                    continue;
+                }
+
+                if (!forum_user_can_see_post($forum, $discussion, $post, $this->recipient, $cm)) {
+                    // User cannot see this post.
+                    // Permissions may have changed since it was queued.
+                    continue;
+                }
+
+                $this->add_post_body($author, $post, $discussion, $forum, $cm, $course);
+                $discussionpostcount++;
+            }
+
+            // Add the forum footer.
+            $this->add_discussion_footer($discussion, $forum, $course);
+
+            // Add the data for this discussion to the notification body.
+            if ($discussionpostcount) {
+                $this->sentcount += $discussionpostcount;
+                $this->notificationtext .= $this->discussiontext;
+                $this->notificationhtml .= $this->discussionhtml;
+                $this->log_finish("Added {$discussionpostcount} messages to discussion {$discussion->id}", 1);
+            } else {
+                $this->log_finish("No messages found in discussion {$discussion->id} - skipped.", 1);
+            }
+        }
+
+        if ($this->sentcount) {
+            // This digest has at least one post and should therefore be sent.
+            if ($this->send_mail()) {
+                $this->log_finish("Digest sent with {$this->sentcount} messages.");
+                if (get_user_preferences('forum_markasreadonnotification', 1, $this->recipient->id) == 1) {
+                    forum_tp_mark_posts_read($this->recipient, $this->markpostsasread);
+                }
+            } else {
+                $this->log_finish("Issue sending digest. Skipping.");
+            }
+        } else {
+            $this->log_finish("No messages found to send.");
+        }
+
+        // We have finishied all digest emails, update $CFG->digestmailtimelast.
+        set_config('digestmailtimelast', $starttime);
+    }
+
+    /**
+     * Prepare the data for this run.
+     *
+     * Note: This will also remove posts from the queue.
+     *
+     * @param   int     $timenow
+     */
+    protected function prepare_data(int $timenow) {
+        global $DB;
+
+        $sql = "SELECT p.*, f.id AS forum, f.course
+                  FROM {forum_queue} q
+            INNER JOIN {forum_posts} p ON p.id = q.postid
+            INNER JOIN {forum_discussions} d ON d.id = p.discussion
+            INNER JOIN {forum} f ON f.id = d.forum
+                 WHERE q.userid = :userid
+                   AND q.timemodified < :timemodified
+              ORDER BY d.id, q.timemodified ASC";
+
+        $queueparams = [
+                'userid' => $this->recipient->id,
+                'timemodified' => $timenow,
+            ];
+
+        $posts = $DB->get_recordset_sql($sql, $queueparams);
+        $discussionids = [];
+        $forumids = [];
+        $courseids = [];
+        $userids = [];
+        foreach ($posts as $post) {
+            $discussionids[] = $post->discussion;
+            $forumids[] = $post->forum;
+            $courseids[] = $post->course;
+            $userids[] = $post->userid;
+            unset($post->forum);
+            if (!isset($this->posts[$post->discussion])) {
+                $this->posts[$post->discussion] = [];
+            }
+            $this->posts[$post->discussion][$post->id] = $post;
+        }
+        $posts->close();
+
+        list($in, $params) = $DB->get_in_or_equal($discussionids);
+        $this->discussions = $DB->get_records_select('forum_discussions', "id {$in}", $params);
+
+        list($in, $params) = $DB->get_in_or_equal($forumids);
+        $this->forums = $DB->get_records_select('forum', "id {$in}", $params);
+
+        list($in, $params) = $DB->get_in_or_equal($courseids);
+        $this->courses = $DB->get_records_select('course', "id $in", $params);
+
+        list($in, $params) = $DB->get_in_or_equal($userids);
+        $this->users = $DB->get_records_select('user', "id $in", $params);
+
+        $this->fill_digest_cache();
+
+        $DB->delete_records_select('forum_queue', "userid = :userid AND timemodified < :timemodified", $queueparams);
+    }
+
+    /**
+     * Fill the cron digest cache.
+     */
+    protected function fill_digest_cache() {
+        global $DB;
+
+        $this->forumdigesttypes = $DB->get_records_menu('forum_digests', [
+                'userid' => $this->recipient->id,
+            ], '', 'forum, maildigest');
+    }
+
+    /**
+     * Fetch and initialise the post author.
+     *
+     * @param   int         $userid The id of the user to fetch
+     * @param   \stdClass   $course
+     * @param   \stdClass   $forum
+     * @param   \stdClass   $cm
+     * @param   \context    $context
+     * @return  \stdClass
+     */
+    protected function get_post_author($userid, $course, $forum, $cm, $context) {
+        if (!isset($this->users[$userid])) {
+            // This user no longer exists.
+            return false;
+        }
+
+        $user = $this->users[$userid];
+
+        if (!isset($user->groups)) {
+            // Initialise the groups list.
+            $user->groups = [];
+        }
+
+        if (!isset($user->groups[$forum->id])) {
+            $user->groups[$forum->id] = groups_get_all_groups($course->id, $user->id, $cm->groupingid);
+        }
+
+        // Clone the user object to prevent leaks between messages.
+        return (object) (array) $user;
+    }
+
+    /**
+     * Add the header to this message.
+     */
+    protected function add_message_header() {
+        $site = get_site();
+
+        // Set the subject of the message.
+        $this->postsubject = get_string('digestmailsubject', 'forum', format_string($site->shortname, true));
+
+        // And the content of the header in body.
+        $headerdata = (object) [
+            'sitename' => format_string($site->fullname, true),
+            'userprefs' => (new \moodle_url('/user/forum.php', [
+                    'id' => $this->recipient->id,
+                    'course' => $site->id,
+                ]))->out(false),
+            ];
+
+        $this->notificationtext .= get_string('digestmailheader', 'forum', $headerdata) . "\n";
+
+        if ($this->allowhtml) {
+            $headerdata->userprefs = html_writer::link($headerdata->userprefs, get_string('digestmailprefs', 'forum'), [
+                    'target' => '_blank',
+                ]);
+
+            $this->notificationhtml .= html_writer::tag('p', get_string('digestmailheader', 'forum', $headerdata));
+            $this->notificationhtml .= html_writer::empty_tag('br');
+            $this->notificationhtml .= html_writer::empty_tag('hr', [
+                    'size' => 1,
+                    'noshade' => 'noshade',
+                ]);
+        }
+    }
+
+    /**
+     * Add the header for this discussion.
+     *
+     * @param   \stdClass   $discussion The discussion to add the footer for
+     * @param   \stdClass   $forum The forum that the discussion belongs to
+     * @param   \stdClass   $course The course that the forum belongs to
+     */
+    protected function add_discussion_header($discussion, $forum, $course) {
+        global $CFG;
+
+        $shortname = format_string($course->shortname, true, [
+                'context' => \context_course::instance($course->id),
+            ]);
+
+        $strforums = get_string('forums', 'forum');
+
+        $this->discussiontext .= "\n=====================================================================\n\n";
+        $this->discussiontext .= "$shortname -> $strforums -> " . format_string($forum->name, true);
+        if ($discussion->name != $forum->name) {
+            $this->discussiontext  .= " -> " . format_string($discussion->name, true);
+        }
+        $this->discussiontext .= "\n";
+        $this->discussiontext .= new \moodle_url('/mod/forum/discuss.php', [
+                'd' => $discussion->id,
+            ]);
+        $this->discussiontext .= "\n";
+
+        if ($this->allowhtml) {
+            $this->discussionhtml .= "<p><font face=\"sans-serif\">".
+                "<a target=\"_blank\" href=\"$CFG->wwwroot/course/view.php?id=$course->id\">$shortname</a> -> ".
+                "<a target=\"_blank\" href=\"$CFG->wwwroot/mod/forum/index.php?id=$course->id\">$strforums</a> -> ".
+                "<a target=\"_blank\" href=\"$CFG->wwwroot/mod/forum/view.php?f=$forum->id\">" .
+                        format_string($forum->name, true)."</a>";
+            if ($discussion->name == $forum->name) {
+                $this->discussionhtml .= "</font></p>";
+            } else {
+                $this->discussionhtml .=
+                        " -> <a target=\"_blank\" href=\"$CFG->wwwroot/mod/forum/discuss.php?d=$discussion->id\">" .
+                        format_string($discussion->name, true)."</a></font></p>";
+            }
+            $this->discussionhtml .= '<p>';
+        }
+
+    }
+
+    /**
+     * Add the body of this post.
+     *
+     * @param   \stdClass   $author The author of the post
+     * @param   \stdClass   $post The post being sent
+     * @param   \stdClass   $discussion The discussion that the post is in
+     * @param   \stdClass   $forum The forum that the discussion belongs to
+     * @param   \cminfo     $cm The cminfo object for the forum
+     * @param   \stdClass   $course The course that the forum belongs to
+     */
+    protected function add_post_body($author, $post, $discussion, $forum, $cm, $course) {
+        global $CFG;
+
+        $canreply = $this->canpostto[$discussion->id];
+
+        $data = new \mod_forum\output\forum_post_email(
+            $course,
+            $cm,
+            $forum,
+            $discussion,
+            $post,
+            $author,
+            $this->recipient,
+            $canreply
+        );
+
+        // Override the viewfullnames value.
+        $data->viewfullnames = $this->viewfullnames[$forum->id];
+
+        // Determine the type of digest being sent.
+        $maildigest = $this->get_maildigest($forum->id);
+
+        $textrenderer = $this->get_renderer($maildigest);
+        $this->discussiontext .= $textrenderer->render($data);
+        $this->discussiontext .= "\n";
+        if ($this->allowhtml) {
+            $htmlrenderer = $this->get_renderer($maildigest, true);
+            $this->discussionhtml .= $htmlrenderer->render($data);
+            $this->log("Adding post {$post->id} in format {$maildigest} with HTML", 2);
+        } else {
+            $this->log("Adding post {$post->id} in format {$maildigest} without HTML", 2);
+        }
+
+        if ($maildigest == 1 && $CFG->forum_usermarksread) {
+            // Create an array of postid's for this user to mark as read.
+            $this->markpostsasread[] = $post->id;
+        }
+
+    }
+
+    /**
+     * Add the footer for this discussion.
+     *
+     * @param   \stdClass   $discussion The discussion to add the footer for
+     */
+    protected function add_discussion_footer($discussion) {
+        global $CFG;
+
+        if ($this->allowhtml) {
+            $footerlinks = [];
+
+            $forum = $this->forums[$discussion->forum];
+            if (\mod_forum\subscriptions::is_forcesubscribed($forum)) {
+                // This forum is force subscribed. The user cannot unsubscribe.
+                $footerlinks[] = get_string("everyoneissubscribed", "forum");
+            } else {
+                $footerlinks[] = "<a href=\"$CFG->wwwroot/mod/forum/subscribe.php?id=$forum->id\">" .
+                    get_string("unsubscribe", "forum") . "</a>";
+            }
+            $footerlinks[] = "<a href='{$CFG->wwwroot}/mod/forum/index.php?id={$forum->course}'>" .
+                    get_string("digestmailpost", "forum") . '</a>';
+
+            $this->discussionhtml .= "\n<div class='mdl-right'><font size=\"1\">" .
+                    implode('&nbsp;', $footerlinks) . '</font></div>';
+            $this->discussionhtml .= '<hr size="1" noshade="noshade" /></p>';
+        }
+    }
+
+    /**
+     * Get the forum digest type for the specified forum, failing back to
+     * the default setting for the current user if not specified.
+     *
+     * @param   int     $forumid
+     * @return  int
+     */
+    protected function get_maildigest($forumid) {
+        $maildigest = -1;
+
+        if (isset($this->forumdigesttypes[$forumid])) {
+            $maildigest = $this->forumdigesttypes[$forumid];
+        }
+
+        if ($maildigest === -1 && !empty($this->recipient->maildigest)) {
+            $maildigest = $this->recipient->maildigest;
+        }
+
+        if ($maildigest === -1) {
+            // There is no maildigest type right now.
+            $maildigest = 1;
+        }
+
+        return $maildigest;
+    }
+
+    /**
+     * Send the composed message to the user.
+     */
+    protected function send_mail() {
+        // Headers to help prevent auto-responders.
+        $userfrom = \core_user::get_noreply_user();
+        $userfrom->customheaders = array(
+            "Precedence: Bulk",
+            'X-Auto-Response-Suppress: All',
+            'Auto-Submitted: auto-generated',
+        );
+
+        $eventdata = new \core\message\message();
+        $eventdata->courseid = SITEID;
+        $eventdata->component = 'mod_forum';
+        $eventdata->name = 'digests';
+        $eventdata->userfrom = $userfrom;
+        $eventdata->userto = $this->recipient;
+        $eventdata->subject = $this->postsubject;
+        $eventdata->fullmessage = $this->notificationtext;
+        $eventdata->fullmessageformat = FORMAT_PLAIN;
+        $eventdata->fullmessagehtml = $this->notificationhtml;
+        $eventdata->notification = 1;
+        $eventdata->smallmessage = get_string('smallmessagedigest', 'forum', $this->sentcount);
+
+        return message_send($eventdata);
+    }
+
+    /**
+     * Helper to fetch the required renderer, instantiating as required.
+     *
+     * @param   int     $maildigest The type of mail digest being sent
+     * @param   bool    $html Whether to fetch the HTML renderer
+     * @return  \core_renderer
+     */
+    protected function get_renderer($maildigest, $html = false) {
+        global $PAGE;
+
+        $type = $maildigest == 2 ? 'emaildigestbasic' : 'emaildigestfull';
+        $target = $html ? 'htmlemail' : 'textemail';
+
+        if (!isset($this->renderers[$target][$type])) {
+            $this->renderers[$target][$type] = $PAGE->get_renderer('mod_forum', $type, $target);
+        }
+
+        return $this->renderers[$target][$type];
+    }
+}
diff --git a/mod/forum/classes/task/send_user_notifications.php b/mod/forum/classes/task/send_user_notifications.php
new file mode 100644 (file)
index 0000000..cd146d5
--- /dev/null
@@ -0,0 +1,534 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle 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.
+//
+// Moodle 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 Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * This file defines an adhoc task to send notifications.
+ *
+ * @package    mod_forum
+ * @copyright  2018 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_forum\task;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Adhoc task to send user forum notifications.
+ *
+ * @package    mod_forum
+ * @copyright  2018 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class send_user_notifications extends \core\task\adhoc_task {
+
+    // Use the logging trait to get some nice, juicy, logging.
+    use \core\task\logging_trait;
+
+    /**
+     * @var \stdClass   A shortcut to $USER.
+     */
+    protected $recipient;
+
+    /**
+     * @var \stdClass[] List of courses the messages are in, indexed by courseid.
+     */
+    protected $courses = [];
+
+    /**
+     * @var \stdClass[] List of forums the messages are in, indexed by courseid.
+     */
+    protected $forums = [];
+
+    /**
+     * @var int[] List of IDs for forums in each course.
+     */
+    protected $courseforums = [];
+
+    /**
+     * @var \stdClass[] List of discussions the messages are in, indexed by forumid.
+     */
+    protected $discussions = [];
+
+    /**
+     * @var \stdClass[] List of IDs for discussions in each forum.
+     */
+    protected $forumdiscussions = [];
+
+    /**
+     * @var \stdClass[] List of posts the messages are in, indexed by discussionid.
+     */
+    protected $posts = [];
+
+    /**
+     * @var bool[] Whether the user can view fullnames for each forum.
+     */
+    protected $viewfullnames = [];
+
+    /**
+     * @var bool[] Whether the user can post in each discussion.
+     */
+    protected $canpostto = [];
+
+    /**
+     * @var \renderer[] The renderers.
+     */
+    protected $renderers = [];
+
+    /**
+     * @var \core\message\inbound\address_manager The inbound message address manager.
+     */
+    protected $inboundmanager;
+
+    /**
+     * Send out messages.
+     */
+    public function execute() {
+        global $CFG;
+
+        $this->recipient = \core_user::get_user($this->get_userid());
+
+        // Create the generic messageinboundgenerator.
+        $this->inboundmanager = new \core\message\inbound\address_manager();
+        $this->inboundmanager->set_handler('\mod_forum\message\inbound\reply_handler');
+
+        $data = $this->get_custom_data();
+
+        $this->prepare_data((array) $data);
+
+        $markposts = [];
+        $errorcount = 0;
+        $sentcount = 0;
+        $this->log_start("Sending messages to {$this->recipient->username} ({$this->recipient->id})");
+        foreach ($this->courses as $course) {
+            $coursecontext = \context_course::instance($course->id);
+            if (!$course->visible and !has_capability('moodle/course:viewhiddencourses', $coursecontext)) {
+                // The course is hidden and the user does not have access to it.
+                // Permissions may have changed since it was queued.
+                continue;
+            }
+            foreach ($this->courseforums[$course->id] as $forumid) {
+                $forum = $this->forums[$forumid];
+
+                $cm = get_fast_modinfo($course)->instances['forum'][$forumid];
+                $modcontext = \context_module::instance($cm->id);
+
+                foreach (array_values($this->forumdiscussions[$forumid]) as $discussionid) {
+                    $discussion = $this->discussions[$discussionid];
+
+                    if (!forum_user_can_see_discussion($forum, $discussion, $modcontext, $this->recipient)) {
+                        // User cannot see this discussion.
+                        // Permissions may have changed since it was queued.
+                        continue;
+                    }
+
+                    if (!\mod_forum\subscriptions::is_subscribed($this->recipient->id, $forum, $discussionid, $cm)) {
+                        // The user does not subscribe to this forum as a whole, or to this specific discussion.
+                        continue;
+                    }
+
+                    foreach ($this->posts[$discussionid] as $post) {
+                        if (!forum_user_can_see_post($forum, $discussion, $post, $this->recipient, $cm)) {
+                            // User cannot see this post.
+                            // Permissions may have changed since it was queued.
+                            continue;
+                        }
+
+                        if ($this->send_post($course, $forum, $discussion, $post, $cm, $modcontext)) {
+                            $this->log("Post {$post->id} sent", 1);
+                            // Mark post as read if forum_usermarksread is set off.
+                            if (!$CFG->forum_usermarksread) {
+                                $markposts[$post->id] = true;
+                            }
+                            $sentcount++;
+                        } else {
+                            $this->log("Failed to send post {$post->id}", 1);
+                            $errorcount++;
+                        }
+                    }
+                }
+            }
+        }
+
+        $this->log_finish("Sent {$sentcount} messages with {$errorcount} failures");
+        if (!empty($markposts)) {
+            if (get_user_preferences('forum_markasreadonnotification', 1, $this->recipient->id) == 1) {
+                $this->log_start("Marking posts as read");
+                $count = count($markposts);
+                forum_tp_mark_posts_read($this->recipient, $markposts);
+                $this->log_finish("Marked {$count} posts as read");
+            }
+        }
+    }
+
+    /**
+     * Prepare all data for this run.
+     *
+     * Take all post ids, and fetch the relevant authors, discussions, forums, and courses for them.
+     *
+     * @param   int[]   $postids The list of post IDs
+     */
+    protected function prepare_data(array $postids) {
+        global $DB;
+
+        if (empty($postids)) {
+            return;
+        }
+
+        list($in, $params) = $DB->get_in_or_equal(array_values($postids));
+        $sql = "SELECT p.*, f.id AS forum, f.course
+                  FROM {forum_posts} p
+            INNER JOIN {forum_discussions} d ON d.id = p.discussion
+            INNER JOIN {forum} f ON f.id = d.forum
+                 WHERE p.id {$in}";
+
+        $posts = $DB->get_recordset_sql($sql, $params);
+        $discussionids = [];
+        $forumids = [];
+        $courseids = [];
+        $userids = [];
+        foreach ($posts as $post) {
+            $discussionids[] = $post->discussion;
+            $forumids[] = $post->forum;
+            $courseids[] = $post->course;
+            $userids[] = $post->userid;
+            unset($post->forum);
+            if (!isset($this->posts[$post->discussion])) {
+                $this->posts[$post->discussion] = [];
+            }
+            $this->posts[$post->discussion][$post->id] = $post;
+        }
+        $posts->close();
+
+        // Fetch all discussions.
+        list($in, $params) = $DB->get_in_or_equal(array_values($discussionids));
+        $this->discussions = $DB->get_records_select('forum_discussions', "id {$in}", $params);
+        foreach ($this->discussions as $discussion) {
+            if (empty($this->forumdiscussions[$discussion->forum])) {
+                $this->forumdiscussions[$discussion->forum] = [];
+            }
+            $this->forumdiscussions[$discussion->forum][] = $discussion->id;
+        }
+
+        // Fetch all forums.
+        list($in, $params) = $DB->get_in_or_equal(array_values($forumids));
+        $this->forums = $DB->get_records_select('forum', "id {$in}", $params);
+        foreach ($this->forums as $forum) {
+            if (empty($this->courseforums[$forum->course])) {
+                $this->courseforums[$forum->course] = [];
+            }
+            $this->courseforums[$forum->course][] = $forum->id;
+        }
+
+        // Fetch all courses.
+        list($in, $params) = $DB->get_in_or_equal(array_values($courseids));
+        $this->courses = $DB->get_records_select('course', "id $in", $params);
+
+        // Fetch all authors.
+        list($in, $params) = $DB->get_in_or_equal(array_values($userids));
+        $users = $DB->get_recordset_select('user', "id $in", $params);
+        foreach ($users as $user) {
+            $this->minimise_user_record($user);
+            $this->users[$user->id] = $user;
+        }
+        $users->close();
+
+        // Fill subscription caches for each forum.
+        // These are per-user.
+        foreach (array_values($forumids) as $id) {
+            \mod_forum\subscriptions::fill_subscription_cache($id);
+            \mod_forum\subscriptions::fill_discussion_subscription_cache($id);
+        }
+    }
+
+    /**
+     * Send the specified post for the current user.
+     *
+     * @param   \stdClass   $course
+     * @param   \stdClass   $forum
+     * @param   \stdClass   $discussion
+     * @param   \stdClass   $post
+     * @param   \stdClass   $cm
+     * @param   \context    $context
+     */
+    protected function send_post($course, $forum, $discussion, $post, $cm, $context) {
+        global $CFG;
+
+        $author = $this->get_post_author($post->userid, $course, $forum, $cm, $context);
+        if (empty($author)) {
+            return false;
+        }
+
+        // Prepare to actually send the post now, and build up the content.
+        $cleanforumname = str_replace('"', "'", strip_tags(format_string($forum->name)));
+
+        $shortname = format_string($course->shortname, true, [
+                'context' => \context_course::instance($course->id),
+            ]);
+
+        // Generate a reply-to address from using the Inbound Message handler.
+        $replyaddress = $this->get_reply_address($course, $forum, $discussion, $post, $cm, $context);
+
+        $data = new \mod_forum\output\forum_post_email(
+            $course,
+            $cm,
+            $forum,
+            $discussion,
+            $post,
+            $author,
+            $this->recipient,
+            $this->can_post($course, $forum, $discussion, $post, $cm, $context)
+        );
+        $data->viewfullnames = $this->can_view_fullnames($course, $forum, $discussion, $post, $cm, $context);
+
+        // Not all of these variables are used in the default string but are made available to support custom subjects.
+        $site = get_site();
+        $a = (object) [
+            'subject' => $data->get_subject(),
+            'forumname' => $cleanforumname,
+            'sitefullname' => format_string($site->fullname),
+            'siteshortname' => format_string($site->shortname),
+            'courseidnumber' => $data->get_courseidnumber(),
+            'coursefullname' => $data->get_coursefullname(),
+            'courseshortname' => $data->get_coursename(),
+        ];
+        $postsubject = html_to_text(get_string('postmailsubject', 'forum', $a), 0);
+
+        // Message headers are stored against the message author.
+        $author->customheaders = $this->get_message_headers($course, $forum, $discussion, $post, $a, $data);
+
+        $eventdata = new \core\message\message();
+        $eventdata->courseid            = $course->id;
+        $eventdata->component           = 'mod_forum';
+        $eventdata->name                = 'posts';
+        $eventdata->userfrom            = $author;
+        $eventdata->userto              = $this->recipient;
+        $eventdata->subject             = $postsubject;
+        $eventdata->fullmessage         = $this->get_renderer()->render($data);
+        $eventdata->fullmessageformat   = FORMAT_PLAIN;
+        $eventdata->fullmessagehtml     = $this->get_renderer(true)->render($data);
+        $eventdata->notification        = 1;
+        $eventdata->replyto             = $replyaddress;
+        if (!empty($replyaddress)) {
+            // Add extra text to email messages if they can reply back.
+            $eventdata->set_additional_content('email', [
+                    'fullmessage' => [
+                        'footer' => "\n\n" . get_string('replytopostbyemail', 'mod_forum'),
+                    ],
+                    'fullmessagehtml' => [
+                        'footer' => \html_writer::tag('p', get_string('replytopostbyemail', 'mod_forum')),
+                    ]
+                ]);
+        }
+
+        $eventdata->smallmessage = get_string('smallmessage', 'forum', (object) [
+                'user' => fullname($author),
+                'forumname' => "$shortname: " . format_string($forum->name, true) . ": " . $discussion->name,
+                'message' => $post->message,
+            ]);
+
+        $contexturl = new \moodle_url('/mod/forum/discuss.php', ['d' => $discussion->id], "p{$post->id}");
+        $eventdata->contexturl = $contexturl->out();
+        $eventdata->contexturlname = $discussion->name;
+
+        return message_send($eventdata);
+    }
+
+    /**
+     * Fetch and initialise the post author.
+     *
+     * @param   int         $userid The id of the user to fetch
+     * @param   \stdClass   $course
+     * @param   \stdClass   $forum
+     * @param   \stdClass   $cm
+     * @param   \context    $context
+     * @return  \stdClass
+     */
+    protected function get_post_author($userid, $course, $forum, $cm, $context) {
+        if (!isset($this->users[$userid])) {
+            // This user no longer exists.
+            return false;
+        }
+
+        $user = $this->users[$userid];
+
+        if (!isset($user->groups)) {
+            // Initialise the groups list.
+            $user->groups = [];
+        }
+
+        if (!isset($user->groups[$forum->id])) {
+            $user->groups[$forum->id] = groups_get_all_groups($course->id, $user->id, $cm->groupingid);
+        }
+
+        // Clone the user object to prevent leaks between messages.
+        return (object) (array) $user;
+    }
+
+    /**
+     * Helper to fetch the required renderer, instantiating as required.
+     *
+     * @param   bool    $html Whether to fetch the HTML renderer
+     * @return  \core_renderer
+     */
+    protected function get_renderer($html = false) {
+        global $PAGE;
+
+        $target = $html ? 'htmlemail' : 'textemail';
+
+        if (!isset($this->renderers[$target])) {
+            $this->renderers[$target] = $PAGE->get_renderer('mod_forum', 'email', $target);
+        }
+
+        return $this->renderers[$target];
+    }
+
+    /**
+     * Get the list of message headers.
+     *
+     * @param   \stdClass   $course
+     * @param   \stdClass   $forum
+     * @param   \stdClass   $discussion
+     * @param   \stdClass   $post
+     * @param   \stdClass   $a The list of strings for this  post
+     * @param   \core\message\message $message The message to be sent
+     * @return  \stdClass
+     */
+    protected function get_message_headers($course, $forum, $discussion, $post, $a, $message) {
+        $cleanforumname = str_replace('"', "'", strip_tags(format_string($forum->name)));
+        $viewurl = new \moodle_url('/mod/forum/view.php', ['f' => $forum->id]);
+
+        $headers = [
+            // Headers to make emails easier to track.
+            'List-Id: "' . $cleanforumname . '" ' . generate_email_messageid('moodleforum' . $forum->id),
+            'List-Help: ' . $viewurl->out(),
+            'Message-ID: ' . forum_get_email_message_id($post->id, $this->recipient->id),
+            'X-Course-Id: ' . $course->id,
+            'X-Course-Name: '. format_string($course->fullname, true),
+
+            // Headers to help prevent auto-responders.
+            'Precedence: Bulk',
+            'X-Auto-Response-Suppress: All',
+            'Auto-Submitted: auto-generated',
+            'List-Unsubscribe: <' . $message->get_unsubscribediscussionlink() . '>',
+        ];
+
+        $rootid = forum_get_email_message_id($discussion->firstpost, $this->recipient->id);
+
+        if ($post->parent) {
+            // This post is a reply, so add reply header (RFC 2822).
+            $parentid = forum_get_email_message_id($post->parent, $this->recipient->id);
+            $headers[] = "In-Reply-To: $parentid";
+
+            // If the post is deeply nested we also reference the parent message id and
+            // the root message id (if different) to aid threading when parts of the email
+            // conversation have been deleted (RFC1036).
+            if ($post->parent != $discussion->firstpost) {
+                $headers[] = "References: $rootid $parentid";
+            } else {
+                $headers[] = "References: $parentid";
+            }
+        }
+
+        // MS Outlook / Office uses poorly documented and non standard headers, including
+        // Thread-Topic which overrides the Subject and shouldn't contain Re: or Fwd: etc.
+        $aclone = (object) (array) $a;
+        $aclone->subject = $discussion->name;
+        $threadtopic = html_to_text(get_string('postmailsubject', 'forum', $aclone), 0);
+        $headers[] = "Thread-Topic: $threadtopic";
+        $headers[] = "Thread-Index: " . substr($rootid, 1, 28);
+
+        return $headers;
+    }
+
+    /**
+     * Get a no-reply address for this user to reply to the current post.
+     *
+     * @param   \stdClass   $course
+     * @param   \stdClass   $forum
+     * @param   \stdClass   $discussion
+     * @param   \stdClass   $post
+     * @param   \stdClass   $cm
+     * @param   \context    $context
+     * @return  string
+     */
+    protected function get_reply_address($course, $forum, $discussion, $post, $cm, $context) {
+        if ($this->can_post($course, $forum, $discussion, $post, $cm, $context)) {
+            // Generate a reply-to address from using the Inbound Message handler.
+            $this->inboundmanager->set_data($post->id);
+            return $this->inboundmanager->generate($this->recipient->id);
+        }
+
+        // TODO Check if we can return a string.
+        // This will be controlled by the event.
+        return null;
+    }
+
+    /**
+     * Check whether the user can post.
+     *
+     * @param   \stdClass   $course
+     * @param   \stdClass   $forum
+     * @param   \stdClass   $discussion
+     * @param   \stdClass   $post
+     * @param   \stdClass   $cm
+     * @param   \context    $context
+     * @return  bool
+     */
+    protected function can_post($course, $forum, $discussion, $post, $cm, $context) {
+        if (!isset($this->canpostto[$discussion->id])) {
+            $this->canpostto[$discussion->id] = forum_user_can_post($forum, $discussion, $this->recipient, $cm, $course, $context);
+        }
+        return $this->canpostto[$discussion->id];
+    }
+
+    /**
+     * Check whether the user can view full names of other users.
+     *
+     * @param   \stdClass   $course
+     * @param   \stdClass   $forum
+     * @param   \stdClass   $discussion
+     * @param   \stdClass   $post
+     * @param   \stdClass   $cm
+     * @param   \context    $context
+     * @return  bool
+     */
+    protected function can_view_fullnames($course, $forum, $discussion, $post, $cm, $context) {
+        if (!isset($this->viewfullnames[$forum->id])) {
+            $this->viewfullnames[$forum->id] = has_capability('moodle/site:viewfullnames', $context, $this->recipient->id);
+        }
+
+        return $this->viewfullnames[$forum->id];
+    }
+
+    /**
+     * Removes properties from user record that are not necessary for sending post notifications.
+     *
+     * @param   \stdClass   $user
+     */
+    protected function minimise_user_record(\stdClass $user) {
+        // We store large amount of users in one huge array, make sure we do not store info there we do not actually
+        // need in mail generation code or messaging.
+        unset($user->institution);
+        unset($user->department);
+        unset($user->address);
+        unset($user->city);
+        unset($user->url);
+        unset($user->currentlogin);
+        unset($user->description);
+        unset($user->descriptionformat);
+    }
+}
index be682bd..2ca2ee7 100644 (file)
@@ -372,3 +372,41 @@ function forum_make_mail_post($course, $cm, $forum, $discussion, $post, $userfro
 
     return $renderer->render($renderable);
 }
+
+/**
+ * Removes properties from user record that are not necessary for sending post notifications.
+ *
+ * @param stdClass $user
+ * @return void, $user parameter is modified
+ * @deprecated since Moodle 3.7
+ */
+function forum_cron_minimise_user_record(stdClass $user) {
+    debugging("forum_cron_minimise_user_record() has been deprecated and has not been replaced.",
+            DEBUG_DEVELOPER);
+
+    // We store large amount of users in one huge array,
+    // make sure we do not store info there we do not actually need
+    // in mail generation code or messaging.
+
+    unset($user->institution);
+    unset($user->department);
+    unset($user->address);
+    unset($user->city);
+    unset($user->url);
+    unset($user->currentlogin);
+    unset($user->description);
+    unset($user->descriptionformat);
+}
+
+/**
+ * Function to be run periodically according to the scheduled task.
+ *
+ * Finds all posts that have yet to be mailed out, and mails them out to all subscribers as well as other maintance
+ * tasks.
+ *
+ * @deprecated since Moodle 3.7
+ */
+function forum_cron() {
+    debugging("forum_cron() has been deprecated and replaced with new tasks. Please uses these instead.",
+            DEBUG_DEVELOPER);
+}
index 50e2866..39c27cc 100644 (file)
@@ -423,829 +423,6 @@ function forum_get_email_message_id($postid, $usertoid) {
     return generate_email_messageid(hash('sha256', $postid . 'to' . $usertoid));
 }
 
-/**
- * Removes properties from user record that are not necessary
- * for sending post notifications.
- * @param stdClass $user
- * @return void, $user parameter is modified
- */
-function forum_cron_minimise_user_record(stdClass $user) {
-
-    // We store large amount of users in one huge array,
-    // make sure we do not store info there we do not actually need
-    // in mail generation code or messaging.
-
-    unset($user->institution);
-    unset($user->department);
-    unset($user->address);
-    unset($user->city);
-    unset($user->url);
-    unset($user->currentlogin);
-    unset($user->description);
-    unset($user->descriptionformat);
-}
-
-/**
- * Function to be run periodically according to the scheduled task.
- *
- * Finds all posts that have yet to be mailed out, and mails them
- * out to all subscribers as well as other maintance tasks.
- *
- * NOTE: Since 2.7.2 this function is run by scheduled task rather
- * than standard cron.
- *
- * @todo MDL-44734 The function will be split up into seperate tasks.
- */
-function forum_cron() {
-    global $CFG, $USER, $DB, $PAGE;
-
-    $site = get_site();
-
-    // The main renderers.
-    $htmlout = $PAGE->get_renderer('mod_forum', 'email', 'htmlemail');
-    $textout = $PAGE->get_renderer('mod_forum', 'email', 'textemail');
-    $htmldigestfullout = $PAGE->get_renderer('mod_forum', 'emaildigestfull', 'htmlemail');
-    $textdigestfullout = $PAGE->get_renderer('mod_forum', 'emaildigestfull', 'textemail');
-    $htmldigestbasicout = $PAGE->get_renderer('mod_forum', 'emaildigestbasic', 'htmlemail');
-    $textdigestbasicout = $PAGE->get_renderer('mod_forum', 'emaildigestbasic', 'textemail');
-
-    // All users that are subscribed to any post that needs sending,
-    // please increase $CFG->extramemorylimit on large sites that
-    // send notifications to a large number of users.
-    $users = array();
-    $userscount = 0; // Cached user counter - count($users) in PHP is horribly slow!!!
-
-    // Status arrays.
-    $mailcount  = array();
-    $errorcount = array();
-
-    // caches
-    $discussions        = array();
-    $forums             = array();
-    $courses            = array();
-    $coursemodules      = array();
-    $subscribedusers    = array();
-    $messageinboundhandlers = array();
-
-    // Posts older than 2 days will not be mailed.  This is to avoid the problem where
-    // cron has not been running for a long time, and then suddenly people are flooded
-    // with mail from the past few weeks or months
-    $timenow   = time();
-    $endtime   = $timenow - $CFG->maxeditingtime;
-    $starttime = $endtime - 48 * 3600;   // Two days earlier
-
-    // Get the list of forum subscriptions for per-user per-forum maildigest settings.
-    $digestsset = $DB->get_recordset('forum_digests', null, '', 'id, userid, forum, maildigest');
-    $digests = array();
-    foreach ($digestsset as $thisrow) {
-        if (!isset($digests[$thisrow->forum])) {
-            $digests[$thisrow->forum] = array();
-        }
-        $digests[$thisrow->forum][$thisrow->userid] = $thisrow->maildigest;
-    }
-    $digestsset->close();
-
-    // Create the generic messageinboundgenerator.
-    $messageinboundgenerator = new \core\message\inbound\address_manager();
-    $messageinboundgenerator->set_handler('\mod_forum\message\inbound\reply_handler');
-
-    if ($posts = forum_get_unmailed_posts($starttime, $endtime, $timenow)) {
-        // Mark them all now as being mailed.  It's unlikely but possible there
-        // might be an error later so that a post is NOT actually mailed out,
-        // but since mail isn't crucial, we can accept this risk.  Doing it now
-        // prevents the risk of duplicated mails, which is a worse problem.
-
-        if (!forum_mark_old_posts_as_mailed($endtime)) {
-            mtrace('Errors occurred while trying to mark some posts as being mailed.');
-            return false;  // Don't continue trying to mail them, in case we are in a cron loop
-        }
-
-        // checking post validity, and adding users to loop through later
-        foreach ($posts as $pid => $post) {
-
-            $discussionid = $post->discussion;
-            if (!isset($discussions[$discussionid])) {
-                if ($discussion = $DB->get_record('forum_discussions', array('id'=> $post->discussion))) {
-                    $discussions[$discussionid] = $discussion;
-                    \mod_forum\subscriptions::fill_subscription_cache($discussion->forum);
-                    \mod_forum\subscriptions::fill_discussion_subscription_cache($discussion->forum);
-
-                } else {
-                    mtrace('Could not find discussion ' . $discussionid);
-                    unset($posts[$pid]);
-                    continue;
-                }
-            }
-            $forumid = $discussions[$discussionid]->forum;
-            if (!isset($forums[$forumid])) {
-                if ($forum = $DB->get_record('forum', array('id' => $forumid))) {
-                    $forums[$forumid] = $forum;
-                } else {
-                    mtrace('Could not find forum '.$forumid);
-                    unset($posts[$pid]);
-                    continue;
-                }
-            }
-            $courseid = $forums[$forumid]->course;
-            if (!isset($courses[$courseid])) {
-                if ($course = $DB->get_record('course', array('id' => $courseid))) {
-                    $courses[$courseid] = $course;
-                } else {
-                    mtrace('Could not find course '.$courseid);
-                    unset($posts[$pid]);
-                    continue;
-                }
-            }
-            if (!isset($coursemodules[$forumid])) {
-                if ($cm = get_coursemodule_from_instance('forum', $forumid, $courseid)) {
-                    $coursemodules[$forumid] = $cm;
-                } else {
-                    mtrace('Could not find course module for forum '.$forumid);
-                    unset($posts[$pid]);
-                    continue;
-                }
-            }
-
-            $modcontext = context_module::instance($coursemodules[$forumid]->id);
-
-            // Save the Inbound Message datakey here to reduce DB queries later.
-            $messageinboundgenerator->set_data($pid);
-            $messageinboundhandlers[$pid] = $messageinboundgenerator->fetch_data_key();
-
-            // Caching subscribed users of each forum.
-            if (!isset($subscribedusers[$forumid])) {
-                if ($subusers = \mod_forum\subscriptions::fetch_subscribed_users($forums[$forumid], 0, $modcontext, 'u.*', true)) {
-
-                    foreach ($subusers as $postuser) {
-                        // this user is subscribed to this forum
-                        $subscribedusers[$forumid][$postuser->id] = $postuser->id;
-                        $userscount++;
-                        if ($userscount > FORUM_CRON_USER_CACHE) {
-                            // Store minimal user info.
-                            $minuser = new stdClass();
-                            $minuser->id = $postuser->id;
-                            $users[$postuser->id] = $minuser;
-                        } else {
-                            // Cache full user record.
-                            forum_cron_minimise_user_record($postuser);
-                            $users[$postuser->id] = $postuser;
-                        }
-                    }
-                    // Release memory.
-                    unset($subusers);
-                    unset($postuser);
-                }
-            }
-            $mailcount[$pid] = 0;
-            $errorcount[$pid] = 0;
-        }
-    }
-
-    if ($users && $posts) {
-
-        foreach ($users as $userto) {
-            // Terminate if processing of any account takes longer than 2 minutes.
-            core_php_time_limit::raise(120);
-
-            mtrace('Processing user ' . $userto->id);
-
-            // Init user caches - we keep the cache for one cycle only, otherwise it could consume too much memory.
-            if (isset($userto->username)) {
-                $userto = clone($userto);
-            } else {
-                $userto = $DB->get_record('user', array('id' => $userto->id));
-                forum_cron_minimise_user_record($userto);
-            }
-            $userto->viewfullnames = array();
-            $userto->canpost       = array();
-            $userto->markposts     = array();
-
-            // Setup this user so that the capabilities are cached, and environment matches receiving user.
-            cron_setup_user($userto);
-
-            // Reset the caches.
-            foreach ($coursemodules as $forumid => $unused) {
-                $coursemodules[$forumid]->cache       = new stdClass();
-                $coursemodules[$forumid]->cache->caps = array();
-                unset($coursemodules[$forumid]->uservisible);
-            }
-
-            foreach ($posts as $pid => $post) {
-                $discussion = $discussions[$post->discussion];
-                $forum      = $forums[$discussion->forum];
-                $course     = $courses[$forum->course];
-                $cm         =& $coursemodules[$forum->id];
-
-                // Do some checks to see if we can bail out now.
-
-                // Only active enrolled users are in the list of subscribers.
-                // This does not necessarily mean that the user is subscribed to the forum or to the discussion though.
-                if (!isset($subscribedusers[$forum->id][$userto->id])) {
-                    // The user does not subscribe to this forum.
-                    continue;
-                }
-
-                if (!\mod_forum\subscriptions::is_subscribed($userto->id, $forum, $post->discussion, $coursemodules[$forum->id])) {
-                    // The user does not subscribe to this forum, or to this specific discussion.
-                    continue;
-                }
-
-                if ($subscriptiontime = \mod_forum\subscriptions::fetch_discussion_subscription($forum->id, $userto->id)) {
-                    // Skip posts if the user subscribed to the discussion after it was created.
-                    if (isset($subscriptiontime[$post->discussion]) && ($subscriptiontime[$post->discussion] > $post->created)) {
-                        continue;
-                    }
-                }
-
-                $coursecontext = context_course::instance($course->id);
-                if (!$course->visible and !has_capability('moodle/course:viewhiddencourses', $coursecontext, $userto->id)) {
-                    // The course is hidden and the user does not have access to it.
-                    continue;
-                }
-
-                // Don't send email if the forum is Q&A and the user has not posted.
-                // Initial topics are still mailed.
-                if ($forum->type == 'qanda' && !forum_get_user_posted_time($discussion->id, $userto->id) && $pid != $discussion->firstpost) {
-                    mtrace('Did not email ' . $userto->id.' because user has not posted in discussion');
-                    continue;
-                }
-
-                // Get info about the sending user.
-                if (array_key_exists($post->userid, $users)) {
-                    // We might know the user already.
-                    $userfrom = $users[$post->userid];
-                    if (!isset($userfrom->idnumber)) {
-                        // Minimalised user info, fetch full record.
-                        $userfrom = $DB->get_record('user', array('id' => $userfrom->id));
-                        forum_cron_minimise_user_record($userfrom);
-                    }
-
-                } else if ($userfrom = $DB->get_record('user', array('id' => $post->userid))) {
-                    forum_cron_minimise_user_record($userfrom);
-                    // Fetch only once if possible, we can add it to user list, it will be skipped anyway.
-                    if ($userscount <= FORUM_CRON_USER_CACHE) {
-                        $userscount++;
-                        $users[$userfrom->id] = $userfrom;
-                    }
-                } else {
-                    mtrace('Could not find user ' . $post->userid . ', author of post ' . $post->id . '. Unable to send message.');
-                    continue;
-                }
-
-                // Note: If we want to check that userto and userfrom are not the same person this is probably the spot to do it.
-
-                // Setup global $COURSE properly - needed for roles and languages.
-                cron_setup_user($userto, $course);
-
-                // Fill caches.
-                if (!isset($userto->viewfullnames[$forum->id])) {
-                    $modcontext = context_module::instance($cm->id);
-                    $userto->viewfullnames[$forum->id] = has_capability('moodle/site:viewfullnames', $modcontext);
-                }
-                if (!isset($userto->canpost[$discussion->id])) {
-                    $modcontext = context_module::instance($cm->id);
-                    $userto->canpost[$discussion->id] = forum_user_can_post($forum, $discussion, $userto, $cm, $course, $modcontext);
-                }
-                if (!isset($userfrom->groups[$forum->id])) {
-                    if (!isset($userfrom->groups)) {
-                        $userfrom->groups = array();
-                        if (isset($users[$userfrom->id])) {
-                            $users[$userfrom->id]->groups = array();
-                        }
-                    }
-                    $userfrom->groups[$forum->id] = groups_get_all_groups($course->id, $userfrom->id, $cm->groupingid);
-                    if (isset($users[$userfrom->id])) {
-                        $users[$userfrom->id]->groups[$forum->id] = $userfrom->groups[$forum->id];
-                    }
-                }
-
-                // Make sure groups allow this user to see this email.
-                if ($discussion->groupid > 0 and $groupmode = groups_get_activity_groupmode($cm, $course)) {
-                    // Groups are being used.
-                    if (!groups_group_exists($discussion->groupid)) {
-                        // Can't find group - be safe and don't this message.
-                        continue;
-                    }
-
-                    if (!groups_is_member($discussion->groupid) and !has_capability('moodle/site:accessallgroups', $modcontext)) {
-                        // Do not send posts from other groups when in SEPARATEGROUPS or VISIBLEGROUPS.
-                        continue;
-                    }
-                }
-
-                // Make sure we're allowed to see the post.
-                if (!forum_user_can_see_post($forum, $discussion, $post, null, $cm)) {
-                    mtrace('User ' . $userto->id .' can not see ' . $post->id . '. Not sending message.');
-                    continue;
-                }
-
-                // OK so we need to send the email.
-
-                // Does the user want this post in a digest?  If so postpone it for now.
-                $maildigest = forum_get_user_maildigest_bulk($digests, $userto, $forum->id);
-
-                if ($maildigest > 0) {
-                    // This user wants the mails to be in digest form.
-                    $queue = new stdClass();
-                    $queue->userid       = $userto->id;
-                    $queue->discussionid = $discussion->id;
-                    $queue->postid       = $post->id;
-                    $queue->timemodified = $post->created;
-                    $DB->insert_record('forum_queue', $queue);
-                    continue;
-                }
-
-                // Prepare to actually send the post now, and build up the content.
-
-                $cleanforumname = str_replace('"', "'", strip_tags(format_string($forum->name)));
-
-                $userfrom->customheaders = array (
-                    // Headers to make emails easier to track.
-                    'List-Id: "'        . $cleanforumname . '" ' . generate_email_messageid('moodleforum' . $forum->id),
-                    'List-Help: '       . $CFG->wwwroot . '/mod/forum/view.php?f=' . $forum->id,
-                    'Message-ID: '      . forum_get_email_message_id($post->id, $userto->id),
-                    'X-Course-Id: '     . $course->id,
-                    'X-Course-Name: '   . format_string($course->fullname, true),
-
-                    // Headers to help prevent auto-responders.
-                    'Precedence: Bulk',
-                    'X-Auto-Response-Suppress: All',
-                    'Auto-Submitted: auto-generated',
-                );
-
-                $shortname = format_string($course->shortname, true, array('context' => context_course::instance($course->id)));
-
-                // Generate a reply-to address from using the Inbound Message handler.
-                $replyaddress = null;
-                if ($userto->canpost[$discussion->id] && array_key_exists($post->id, $messageinboundhandlers)) {
-                    $messageinboundgenerator->set_data($post->id, $messageinboundhandlers[$post->id]);
-                    $replyaddress = $messageinboundgenerator->generate($userto->id);
-                }
-
-                if (!isset($userto->canpost[$discussion->id])) {
-                    $canreply = forum_user_can_post($forum, $discussion, $userto, $cm, $course, $modcontext);
-                } else {
-                    $canreply = $userto->canpost[$discussion->id];
-                }
-
-                $data = new \mod_forum\output\forum_post_email(
-                        $course,
-                        $cm,
-                        $forum,
-                        $discussion,
-                        $post,
-                        $userfrom,
-                        $userto,
-                        $canreply
-                    );
-
-                $userfrom->customheaders[] = sprintf('List-Unsubscribe: <%s>',
-                    $data->get_unsubscribediscussionlink());
-
-                if (!isset($userto->viewfullnames[$forum->id])) {
-                    $data->viewfullnames = has_capability('moodle/site:viewfullnames', $modcontext, $userto->id);
-                } else {
-                    $data->viewfullnames = $userto->viewfullnames[$forum->id];
-                }
-
-                // Not all of these variables are used in the default language
-                // string but are made available to support custom subjects.
-                $a = new stdClass();
-                $a->subject = $data->get_subject();
-                $a->forumname = $cleanforumname;
-                $a->sitefullname = format_string($site->fullname);
-                $a->siteshortname = format_string($site->shortname);
-                $a->courseidnumber = $data->get_courseidnumber();
-                $a->coursefullname = $data->get_coursefullname();
-                $a->courseshortname = $data->get_coursename();
-                $postsubject = html_to_text(get_string('postmailsubject', 'forum', $a), 0);
-
-                $rootid = forum_get_email_message_id($discussion->firstpost, $userto->id);
-
-                if ($post->parent) {
-                    // This post is a reply, so add reply header (RFC 2822).
-                    $parentid = forum_get_email_message_id($post->parent, $userto->id);
-                    $userfrom->customheaders[] = "In-Reply-To: $parentid";
-
-                    // If the post is deeply nested we also reference the parent message id and
-                    // the root message id (if different) to aid threading when parts of the email
-                    // conversation have been deleted (RFC1036).
-                    if ($post->parent != $discussion->firstpost) {
-                        $userfrom->customheaders[] = "References: $rootid $parentid";
-                    } else {
-                        $userfrom->customheaders[] = "References: $parentid";
-                    }
-                }
-
-                // MS Outlook / Office uses poorly documented and non standard headers, including
-                // Thread-Topic which overrides the Subject and shouldn't contain Re: or Fwd: etc.
-                $a->subject = $discussion->name;
-                $threadtopic = html_to_text(get_string('postmailsubject', 'forum', $a), 0);
-                $userfrom->customheaders[] = "Thread-Topic: $threadtopic";
-                $userfrom->customheaders[] = "Thread-Index: " . substr($rootid, 1, 28);
-
-                // Send the post now!
-                mtrace('Sending ', '');
-
-                $eventdata = new \core\message\message();
-                $eventdata->courseid            = $course->id;
-                $eventdata->component           = 'mod_forum';
-                $eventdata->name                = 'posts';
-                $eventdata->userfrom            = $userfrom;
-                $eventdata->userto              = $userto;
-                $eventdata->subject             = $postsubject;
-                $eventdata->fullmessage         = $textout->render($data);
-                $eventdata->fullmessageformat   = FORMAT_PLAIN;
-                $eventdata->fullmessagehtml     = $htmlout->render($data);
-                $eventdata->notification        = 1;
-                $eventdata->replyto             = $replyaddress;
-                if (!empty($replyaddress)) {
-                    // Add extra text to email messages if they can reply back.
-                    $textfooter = "\n\n" . get_string('replytopostbyemail', 'mod_forum');
-                    $htmlfooter = html_writer::tag('p', get_string('replytopostbyemail', 'mod_forum'));
-                    $additionalcontent = array('fullmessage' => array('footer' => $textfooter),
-                                     'fullmessagehtml' => array('footer' => $htmlfooter));
-                    $eventdata->set_additional_content('email', $additionalcontent);
-                }
-
-                $smallmessagestrings = new stdClass();
-                $smallmessagestrings->user          = fullname($userfrom);
-                $smallmessagestrings->forumname     = "$shortname: " . format_string($forum->name, true) . ": " . $discussion->name;
-                $smallmessagestrings->message       = $post->message;
-
-                // Make sure strings are in message recipients language.
-                $eventdata->smallmessage = get_string_manager()->get_string('smallmessage', 'forum', $smallmessagestrings, $userto->lang);
-
-                $contexturl = new moodle_url('/mod/forum/discuss.php', array('d' => $discussion->id), 'p' . $post->id);
-                $eventdata->contexturl = $contexturl->out();
-                $eventdata->contexturlname = $discussion->name;
-
-                $mailresult = message_send($eventdata);
-                if (!$mailresult) {
-                    mtrace("Error: mod/forum/lib.php forum_cron(): Could not send out mail for id $post->id to user $userto->id".
-                            " ($userto->email) .. not trying again.");
-                    $errorcount[$post->id]++;
-                } else {
-                    $mailcount[$post->id]++;
-
-                    // Mark post as read if forum_usermarksread is set off.
-                    if (!$CFG->forum_usermarksread) {
-                        $userto->markposts[$post->id] = $post->id;
-                    }
-                }
-
-                mtrace('post ' . $post->id . ': ' . $post->subject);
-            }
-
-            // Mark processed posts as read.
-            if (get_user_preferences('forum_markasreadonnotification', 1, $userto->id) == 1) {
-                forum_tp_mark_posts_read($userto, $userto->markposts);
-            }
-
-            unset($userto);
-        }
-    }
-
-    if ($posts) {
-        foreach ($posts as $post) {
-            mtrace($mailcount[$post->id]." users were sent post $post->id, '$post->subject'");
-            if ($errorcount[$post->id]) {
-                $DB->set_field('forum_posts', 'mailed', FORUM_MAILED_ERROR, array('id' => $post->id));
-            }
-        }
-    }
-
-    // release some memory
-    unset($subscribedusers);
-    unset($mailcount);
-    unset($errorcount);
-
-    cron_setup_user();
-
-    $sitetimezone = core_date::get_server_timezone();
-
-    // Now see if there are any digest mails waiting to be sent, and if we should send them
-
-    mtrace('Starting digest processing...');
-
-    core_php_time_limit::raise(300); // terminate if not able to fetch all digests in 5 minutes
-
-    if (!isset($CFG->digestmailtimelast)) {    // To catch the first time
-        set_config('digestmailtimelast', 0);
-    }
-
-    $timenow = time();
-    $digesttime = usergetmidnight($timenow, $sitetimezone) + ($CFG->digestmailtime * 3600);
-
-    // Delete any really old ones (normally there shouldn't be any)
-    $weekago = $timenow - (7 * 24 * 3600);
-    $DB->delete_records_select('forum_queue', "timemodified < ?", array($weekago));
-    mtrace ('Cleaned old digest records');
-
-    if ($CFG->digestmailtimelast < $digesttime and $timenow > $digesttime) {
-
-        mtrace('Sending forum digests: '.userdate($timenow, '', $sitetimezone));
-
-        $digestposts_rs = $DB->get_recordset_select('forum_queue', "timemodified < ?", array($digesttime));
-
-        if ($digestposts_rs->valid()) {
-
-            // We have work to do
-            $usermailcount = 0;
-
-            //caches - reuse the those filled before too
-            $discussionposts = array();
-            $userdiscussions = array();
-
-            foreach ($digestposts_rs as $digestpost) {
-                if (!isset($posts[$digestpost->postid])) {
-                    if ($post = $DB->get_record('forum_posts', array('id' => $digestpost->postid))) {
-                        $posts[$digestpost->postid] = $post;
-                    } else {
-                        continue;
-                    }
-                }
-                $discussionid = $digestpost->discussionid;
-                if (!isset($discussions[$discussionid])) {
-                    if ($discussion = $DB->get_record('forum_discussions', array('id' => $discussionid))) {
-                        $discussions[$discussionid] = $discussion;
-                    } else {
-                        continue;
-                    }
-                }
-                $forumid = $discussions[$discussionid]->forum;
-                if (!isset($forums[$forumid])) {
-                    if ($forum = $DB->get_record('forum', array('id' => $forumid))) {
-                        $forums[$forumid] = $forum;
-                    } else {
-                        continue;
-                    }
-                }
-
-                $courseid = $forums[$forumid]->course;
-                if (!isset($courses[$courseid])) {
-                    if ($course = $DB->get_record('course', array('id' => $courseid))) {
-                        $courses[$courseid] = $course;
-                    } else {
-                        continue;
-                    }
-                }
-
-                if (!isset($coursemodules[$forumid])) {
-                    if ($cm = get_coursemodule_from_instance('forum', $forumid, $courseid)) {
-                        $coursemodules[$forumid] = $cm;
-                    } else {
-                        continue;
-                    }
-                }
-                $userdiscussions[$digestpost->userid][$digestpost->discussionid] = $digestpost->discussionid;
-                $discussionposts[$digestpost->discussionid][$digestpost->postid] = $digestpost->postid;
-            }
-            $digestposts_rs->close(); /// Finished iteration, let's close the resultset
-
-            // Data collected, start sending out emails to each user
-            foreach ($userdiscussions as $userid => $thesediscussions) {
-
-                core_php_time_limit::raise(120); // terminate if processing of any account takes longer than 2 minutes
-
-                cron_setup_user();
-
-                mtrace(get_string('processingdigest', 'forum', $userid), '... ');
-
-                // First of all delete all the queue entries for this user
-                $DB->delete_records_select('forum_queue', "userid = ? AND timemodified < ?", array($userid, $digesttime));
-
-                // Init user caches - we keep the cache for one cycle only,
-                // otherwise it would unnecessarily consume memory.
-                if (array_key_exists($userid, $users) and isset($users[$userid]->username)) {
-                    $userto = clone($users[$userid]);
-                } else {
-                    $userto = $DB->get_record('user', array('id' => $userid));
-                    forum_cron_minimise_user_record($userto);
-                }
-                $userto->viewfullnames = array();
-                $userto->canpost       = array();
-                $userto->markposts     = array();
-
-                // Override the language and timezone of the "current" user, so that
-                // mail is customised for the receiver.
-                cron_setup_user($userto);
-
-                $postsubject = get_string('digestmailsubject', 'forum', format_string($site->shortname, true));
-
-                $headerdata = new stdClass();
-                $headerdata->sitename = format_string($site->fullname, true);
-                $headerdata->userprefs = $CFG->wwwroot.'/user/forum.php?id='.$userid.'&amp;course='.$site->id;
-
-                $posttext = get_string('digestmailheader', 'forum', $headerdata)."\n\n";
-                $headerdata->userprefs = '<a target="_blank" href="'.$headerdata->userprefs.'">'.get_string('digestmailprefs', 'forum').'</a>';
-
-                $posthtml = '<p>'.get_string('digestmailheader', 'forum', $headerdata).'</p>'
-                    . '<br /><hr size="1" noshade="noshade" />';
-
-                foreach ($thesediscussions as $discussionid) {
-
-                    core_php_time_limit::raise(120);   // to be reset for each post
-
-                    $discussion = $discussions[$discussionid];
-                    $forum      = $forums[$discussion->forum];
-                    $course     = $courses[$forum->course];
-                    $cm         = $coursemodules[$forum->id];
-
-                    //override language
-                    cron_setup_user($userto, $course);
-
-                    // Fill caches
-                    if (!isset($userto->viewfullnames[$forum->id])) {
-                        $modcontext = context_module::instance($cm->id);
-                        $userto->viewfullnames[$forum->id] = has_capability('moodle/site:viewfullnames', $modcontext);
-                    }
-                    if (!isset($userto->canpost[$discussion->id])) {
-                        $modcontext = context_module::instance($cm->id);
-                        $userto->canpost[$discussion->id] = forum_user_can_post($forum, $discussion, $userto, $cm, $course, $modcontext);
-                    }
-
-                    $strforums      = get_string('forums', 'forum');
-                    $canunsubscribe = ! \mod_forum\subscriptions::is_forcesubscribed($forum);
-                    $canreply       = $userto->canpost[$discussion->id];
-                    $shortname = format_string($course->shortname, true, array('context' => context_course::instance($course->id)));
-
-                    $posttext .= "\n \n";
-                    $posttext .= '=====================================================================';
-                    $posttext .= "\n \n";
-                    $posttext .= "$shortname -> $strforums -> ".format_string($forum->name,true);
-                    if ($discussion->name != $forum->name) {
-                        $posttext  .= " -> ".format_string($discussion->name,true);
-                    }
-                    $posttext .= "\n";
-                    $posttext .= $CFG->wwwroot.'/mod/forum/discuss.php?d='.$discussion->id;
-                    $posttext .= "\n";
-
-                    $posthtml .= "<p><font face=\"sans-serif\">".
-                    "<a target=\"_blank\" href=\"$CFG->wwwroot/course/view.php?id=$course->id\">$shortname</a> -> ".
-                    "<a target=\"_blank\" href=\"$CFG->wwwroot/mod/forum/index.php?id=$course->id\">$strforums</a> -> ".
-                    "<a target=\"_blank\" href=\"$CFG->wwwroot/mod/forum/view.php?f=$forum->id\">".format_string($forum->name,true)."</a>";
-                    if ($discussion->name == $forum->name) {
-                        $posthtml .= "</font></p>";
-                    } else {
-                        $posthtml .= " -> <a target=\"_blank\" href=\"$CFG->wwwroot/mod/forum/discuss.php?d=$discussion->id\">".format_string($discussion->name,true)."</a></font></p>";
-                    }
-                    $posthtml .= '<p>';
-
-                    $postsarray = $discussionposts[$discussionid];
-                    sort($postsarray);
-                    $sentcount = 0;
-
-                    foreach ($postsarray as $postid) {
-                        $post = $posts[$postid];
-
-                        if (array_key_exists($post->userid, $users)) { // we might know him/her already
-                            $userfrom = $users[$post->userid];
-                            if (!isset($userfrom->idnumber)) {
-                                $userfrom = $DB->get_record('user', array('id' => $userfrom->id));
-                                forum_cron_minimise_user_record($userfrom);
-                            }
-
-                        } else if ($userfrom = $DB->get_record('user', array('id' => $post->userid))) {
-                            forum_cron_minimise_user_record($userfrom);
-                            if ($userscount <= FORUM_CRON_USER_CACHE) {
-                                $userscount++;
-                                $users[$userfrom->id] = $userfrom;
-                            }
-
-                        } else {
-                            mtrace('Could not find user '.$post->userid);
-                            continue;
-                        }
-
-                        if (!isset($userfrom->groups[$forum->id])) {
-                            if (!isset($userfrom->groups)) {
-                                $userfrom->groups = array();
-                                if (isset($users[$userfrom->id])) {
-                                    $users[$userfrom->id]->groups = array();
-                                }
-                            }
-                            $userfrom->groups[$forum->id] = groups_get_all_groups($course->id, $userfrom->id, $cm->groupingid);
-                            if (isset($users[$userfrom->id])) {
-                                $users[$userfrom->id]->groups[$forum->id] = $userfrom->groups[$forum->id];
-                            }
-                        }
-
-                        // Headers to help prevent auto-responders.
-                        $userfrom->customheaders = array(
-                                "Precedence: Bulk",
-                                'X-Auto-Response-Suppress: All',
-                                'Auto-Submitted: auto-generated',
-                            );
-
-                        $maildigest = forum_get_user_maildigest_bulk($digests, $userto, $forum->id);
-                        if (!isset($userto->canpost[$discussion->id])) {
-                            $canreply = forum_user_can_post($forum, $discussion, $userto, $cm, $course, $modcontext);
-                        } else {
-                            $canreply = $userto->canpost[$discussion->id];
-                        }
-
-                        $data = new \mod_forum\output\forum_post_email(
-                                $course,
-                                $cm,
-                                $forum,
-                                $discussion,
-                                $post,
-                                $userfrom,
-                                $userto,
-                                $canreply
-                            );
-
-                        if (!isset($userto->viewfullnames[$forum->id])) {
-                            $data->viewfullnames = has_capability('moodle/site:viewfullnames', $modcontext, $userto->id);
-                        } else {
-                            $data->viewfullnames = $userto->viewfullnames[$forum->id];
-                        }
-
-                        if ($maildigest == 2) {
-                            // Subjects and link only.
-                            $posttext .= $textdigestbasicout->render($data);
-                            $posthtml .= $htmldigestbasicout->render($data);
-                        } else {
-                            // The full treatment.
-                            $posttext .= $textdigestfullout->render($data);
-                            $posthtml .= $htmldigestfullout->render($data);
-
-                            // Create an array of postid's for this user to mark as read.
-                            if (!$CFG->forum_usermarksread) {
-                                $userto->markposts[$post->id] = $post->id;
-                            }
-                        }
-                        $sentcount++;
-                    }
-                    $footerlinks = array();
-                    if ($canunsubscribe) {
-                        $footerlinks[] = "<a href=\"$CFG->wwwroot/mod/forum/subscribe.php?id=$forum->id\">" . get_string("unsubscribe", "forum") . "</a>";
-                    } else {
-                        $footerlinks[] = get_string("everyoneissubscribed", "forum");
-                    }
-                    $footerlinks[] = "<a href='{$CFG->wwwroot}/mod/forum/index.php?id={$forum->course}'>" . get_string("digestmailpost", "forum") . '</a>';
-                    $posthtml .= "\n<div class='mdl-right'><font size=\"1\">" . implode('&nbsp;', $footerlinks) . '</font></div>';
-                    $posthtml .= '<hr size="1" noshade="noshade" /></p>';
-                }
-
-                if (empty($userto->mailformat) || $userto->mailformat != 1) {
-                    // This user DOESN'T want to receive HTML
-                    $posthtml = '';
-                }
-
-                $eventdata = new \core\message\message();
-                $eventdata->courseid            = SITEID;
-                $eventdata->component           = 'mod_forum';
-                $eventdata->name                = 'digests';
-                $eventdata->userfrom            = core_user::get_noreply_user();
-                $eventdata->userto              = $userto;
-                $eventdata->subject             = $postsubject;
-                $eventdata->fullmessage         = $posttext;
-                $eventdata->fullmessageformat   = FORMAT_PLAIN;
-                $eventdata->fullmessagehtml     = $posthtml;
-                $eventdata->notification        = 1;
-                $eventdata->smallmessage        = get_string('smallmessagedigest', 'forum', $sentcount);
-                $mailresult = message_send($eventdata);
-
-                if (!$mailresult) {
-                    mtrace("ERROR: mod/forum/cron.php: Could not send out digest mail to user $userto->id ".
-                        "($userto->email)... not trying again.");
-                } else {
-                    mtrace("success.");
-                    $usermailcount++;
-
-                    // Mark post as read if forum_usermarksread is set off
-                    if (get_user_preferences('forum_markasreadonnotification', 1, $userto->id) == 1) {
-                        forum_tp_mark_posts_read($userto, $userto->markposts);
-                    }
-                }
-            }
-        }
-    /// We have finishied all digest emails, update $CFG->digestmailtimelast
-        set_config('digestmailtimelast', $timenow);
-    }
-
-    cron_setup_user();
-
-    if (!empty($usermailcount)) {
-        mtrace(get_string('digestsentusers', 'forum', $usermailcount));
-    }
-
-    if (!empty($CFG->forum_lastreadclean)) {
-        $timenow = time();
-        if ($CFG->forum_lastreadclean + (24*3600) < $timenow) {
-            set_config('forum_lastreadclean', $timenow);
-            mtrace('Removing old forum read tracking info...');
-            forum_tp_clean_read_records();
-        }
-    } else {
-        set_config('forum_lastreadclean', time());
-    }
-
-    return true;
-}
-
 /**
  *
  * @param object $course
index 2cec6e7..1c2251a 100644 (file)
@@ -44,7 +44,8 @@
     } {{/ str }}
 ---------------------------------------------------------------------
 {{{ message }}}
-
+{{# attachments }}
 {{{ attachments }}}
+{{/ attachments }}
 ---------------------------------------------------------------------
 {{# str }} digestmailpostlink, forum, {{{ forumindexlink }}} {{/ str }}
diff --git a/mod/forum/tests/cron_trait.php b/mod/forum/tests/cron_trait.php
new file mode 100644 (file)
index 0000000..c68306f
--- /dev/null
@@ -0,0 +1,129 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle 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.
+//
+// Moodle 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 Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * The forum module cron trait.
+ *
+ * @package    mod_forum
+ * @copyright  2018 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+trait mod_forum_tests_cron_trait {
+    /**
+     * Run the main cron task to queue all tasks, and ensure that posts
+     * were sent to the correct users.
+     *
+     * @param   \stdClass[] $expectations The list of users, along with their expected count of messages and digests.
+     */
+    protected function queue_tasks_and_assert($expectations = []) {
+        global $DB;
+
+        // Note, we cannot use expectOutputRegex because it only allows for a single RegExp.
+        ob_start();
+        cron_setup_user();
+        $cron = new \mod_forum\task\cron_task();
+        $cron->execute();
+        $output = ob_get_contents();
+        ob_end_clean();
+
+        $uniqueusers = 0;
+        foreach ($expectations as $expect) {
+            $expect->digests = isset($expect->digests) ? $expect->digests : 0;
+            $expect->messages = isset($expect->messages) ? $expect->messages : 0;
+            $expect->mentioned = isset($expect->mentioned) ? $expect->mentioned : false;
+            if ($expect->digests || $expect->messages) {
+                $expect->mentioned = true;
+            }
+            if (!$expect->mentioned) {
+                $this->assertNotRegExp("/Queued 0 for {$expect->userid}/", $output);
+            } else {
+                $uniqueusers++;
+                $this->assertRegExp(
+                        "/Queued {$expect->digests} digests and {$expect->messages} messages for {$expect->userid}/",
+                        $output
+                    );
+            }
+        }
+
+        if (empty($expectations)) {
+            $this->assertRegExp("/No posts found./", $output);
+        } else {
+            $this->assertRegExp("/Unique users: {$uniqueusers}/", $output);
+        }
+
+        // Update the forum queue for digests.
+        $DB->execute("UPDATE {forum_queue} SET timemodified = timemodified - 1");
+    }
+
+    /**
+     * Run any send_user_notifications tasks for the specified user, and
+     * ensure that the posts specified were sent.
+     *
+     * @param   \stdClass   $user
+     * @param   \stdClass[] $posts
+     * @param   bool        $ignoreemptyposts
+     */
+    protected function send_notifications_and_assert($user, $posts = [], $ignoreemptyposts = false) {
+        ob_start();
+        $this->runAdhocTasks(\mod_forum\task\send_user_notifications::class, $user->id);
+        $output = ob_get_contents();
+        ob_end_clean();
+
+        if (empty($posts) && !$ignoreemptyposts) {
+            $this->assertEquals('', $output);
+        } else {
+            $this->assertRegExp("/Sending messages to {$user->username}/", $output);
+            foreach ($posts as $post) {
+                $this->assertRegExp("/Post {$post->id} sent/", $output);
+            }
+            $count = count($posts);
+            $this->assertRegExp("/Sent {$count} messages with 0 failures/", $output);
+        }
+    }
+
+    /**
+     * Run any send_user_digests tasks for the specified user, and
+     * ensure that the posts specified were sent.
+     *
+     * @param   \stdClass   $user
+     * @param   \stdClass[] $fullposts
+     * @param   \stdClass[] $shortposts
+     */
+    protected function send_digests_and_assert($user, $fullposts = [], $shortposts = []) {
+        ob_start();
+        $this->runAdhocTasks(\mod_forum\task\send_user_digests::class, $user->id);
+        $output = ob_get_contents();
+        ob_end_clean();
+
+        if (empty($shortposts) && empty($fullposts)) {
+            $this->assertEquals('', $output);
+            $this->assertRegExp("/Digest sent with 0 messages./", $output);
+        } else {
+            $this->assertRegExp("/Sending forum digests for {$user->username}/", $output);
+            foreach ($fullposts as $post) {
+                $this->assertRegExp("/Adding post {$post->id} in format 1/", $output);
+            }
+            foreach ($shortposts as $post) {
+                $this->assertRegExp("/Adding post {$post->id} in format 2/", $output);
+            }
+            $count = count($fullposts) + count($shortposts);
+            $this->assertRegExp("/Digest sent with {$count} messages./", $output);
+        }
+    }
+}
diff --git a/mod/forum/tests/generator_trait.php b/mod/forum/tests/generator_trait.php
new file mode 100644 (file)
index 0000000..4c536ab
--- /dev/null
@@ -0,0 +1,159 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle 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.
+//
+// Moodle 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 Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * The forum module trait with additional generator helpers.
+ *
+ * @package    mod_forum
+ * @copyright  2018 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+trait mod_forum_tests_generator_trait {
+
+    /**
+     * Helper to create the required number of users in the specified course.
+     * Users are enrolled as students by default.
+     *
+     * @param   stdClass $course The course object
+     * @param   integer $count The number of users to create
+     * @param   string  $role The role to assign users as
+     * @return  array The users created
+     */
+    protected function helper_create_users($course, $count, $role = null) {
+        $users = array();
+
+        for ($i = 0; $i < $count; $i++) {
+            $user = $this->getDataGenerator()->create_user();
+            $this->getDataGenerator()->enrol_user($user->id, $course->id, $role);
+            $users[] = $user;
+        }
+
+        return $users;
+    }
+
+    /**
+     * Create a new discussion and post within the specified forum, as the
+     * specified author.
+     *
+     * @param stdClass $forum The forum to post in
+     * @param stdClass $author The author to post as
+     * @param array $fields any other fields in discussion (name, message, messageformat, ...)
+     * @return array An array containing the discussion object, and the post object
+     */
+    protected function helper_post_to_forum($forum, $author, $fields = array()) {
+        global $DB;
+        $generator = $this->getDataGenerator()->get_plugin_generator('mod_forum');
+
+        // Create a discussion in the forum, and then add a post to that discussion.
+        $record = (object)$fields;
+        $record->course = $forum->course;
+        $record->userid = $author->id;
+        $record->forum = $forum->id;
+        $discussion = $generator->create_discussion($record);
+
+        // Retrieve the post which was created by create_discussion.
+        $post = $DB->get_record('forum_posts', array('discussion' => $discussion->id));
+
+        return array($discussion, $post);
+    }
+
+    /**
+     * Update the post time for the specified post by $factor.
+     *
+     * @param stdClass $post The post to update
+     * @param int $factor The amount to update by
+     */
+    protected function helper_update_post_time($post, $factor) {
+        global $DB;
+
+        // Update the post to have a created in the past.
+        $DB->set_field('forum_posts', 'created', $post->created + $factor, array('id' => $post->id));
+    }
+
+    /**
+     * Update the subscription time for the specified user/discussion by $factor.
+     *
+     * @param stdClass $user The user to update
+     * @param stdClass $discussion The discussion to update for this user
+     * @param int $factor The amount to update by
+     */
+    protected function helper_update_subscription_time($user, $discussion, $factor) {
+        global $DB;
+
+        $sub = $DB->get_record('forum_discussion_subs', array('userid' => $user->id, 'discussion' => $discussion->id));
+
+        // Update the subscription to have a preference in the past.
+        $DB->set_field('forum_discussion_subs', 'preference', $sub->preference + $factor, array('id' => $sub->id));
+    }
+
+    /**
+     * Create a new post within an existing discussion, as the specified author.
+     *
+     * @param stdClass $forum The forum to post in
+     * @param stdClass $discussion The discussion to post in
+     * @param stdClass $author The author to post as
+     * @return stdClass The forum post
+     */
+    protected function helper_post_to_discussion($forum, $discussion, $author) {
+        global $DB;
+
+        $generator = $this->getDataGenerator()->get_plugin_generator('mod_forum');
+
+        // Add a post to the discussion.
+        $record = new stdClass();
+        $record->course = $forum->course;
+        $strre = get_string('re', 'forum');
+        $record->subject = $strre . ' ' . $discussion->subject;
+        $record->userid = $author->id;
+        $record->forum = $forum->id;
+        $record->discussion = $discussion->id;
+        $record->mailnow = 1;
+
+        $post = $generator->create_post($record);
+
+        return $post;
+    }
+
+    /**
+     * Create a new post within an existing discussion, as the specified author.
+     *
+     * @param stdClass $parent The post being replied to
+     * @param stdClass $author The author to post as
+     * @return stdClass The forum post
+     */
+    protected function helper_reply_to_post($parent, $author) {
+        global $DB;
+
+        $generator = $this->getDataGenerator()->get_plugin_generator('mod_forum');
+
+        // Add a post to the discussion.
+        $strre = get_string('re', 'forum');
+        $record = (object) [
+            'discussion' => $parent->discussion,
+            'parent' => $parent->id,
+            'userid' => $author->id,
+            'mailnow' => 1,
+            'subject' => $strre . ' ' . $parent->subject,
+        ];
+
+        $post = $generator->create_post($record);
+
+        return $post;
+    }
+}
diff --git a/mod/forum/tests/mail_group_test.php b/mod/forum/tests/mail_group_test.php
new file mode 100644 (file)
index 0000000..904151f
--- /dev/null
@@ -0,0 +1,246 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle 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.
+//
+// Moodle 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 Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * The forum module mail generation tests for groups.
+ *
+ * @package    mod_forum
+ * @copyright  2013 Andrew Nicols
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->dirroot . '/mod/forum/lib.php');
+require_once(__DIR__ . '/cron_trait.php');
+require_once(__DIR__ . '/generator_trait.php');
+
+/**
+ * The forum module mail generation tests for groups.
+ *
+ * @copyright  2013 Andrew Nicols
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class mod_forum_mail_group_testcase extends advanced_testcase {
+    // Make use of the cron tester trait.
+    use mod_forum_tests_cron_trait;
+
+    // Make use of the test generator trait.
+    use mod_forum_tests_generator_trait;
+
+    /**
+     * @var \phpunit_message_sink
+     */
+    protected $messagesink;
+
+    /**
+     * @var \phpunit_mailer_sink
+     */
+    protected $mailsink;
+
+    public function setUp() {
+        global $CFG;
+
+        // We must clear the subscription caches. This has to be done both before each test, and after in case of other
+        // tests using these functions.
+        \mod_forum\subscriptions::reset_forum_cache();
+        \mod_forum\subscriptions::reset_discussion_cache();
+
+        // Messaging is not compatible with transactions...
+        $this->preventResetByRollback();
+
+        // Catch all messages.
+        $this->messagesink = $this->redirectMessages();
+        $this->mailsink = $this->redirectEmails();
+
+        // Forcibly reduce the maxeditingtime to a second in the past to
+        // ensure that messages are sent out.
+        $CFG->maxeditingtime = -1;
+    }
+
+    public function tearDown() {
+        // We must clear the subscription caches. This has to be done both before each test, and after in case of other
+        // tests using these functions.
+        \mod_forum\subscriptions::reset_forum_cache();
+
+        $this->messagesink->clear();
+        $this->messagesink->close();
+        unset($this->messagesink);
+
+        $this->mailsink->clear();
+        $this->mailsink->close();
+        unset($this->mailsink);
+    }
+
+    /**
+     * Ensure that posts written in a forum marked for separate groups includes notifications for the members of that
+     * group, and any user with accessallgroups.
+     */
+    public function test_separate_group() {
+        global $CFG, $DB;
+
+        $this->resetAfterTest(true);
+
+        // Create a course, with a forum.
+        $course = $this->getDataGenerator()->create_course();
+
+        $forum = $this->getDataGenerator()->create_module('forum', [
+            'course' => $course->id,
+            'forcesubscribe' => FORUM_INITIALSUBSCRIBE,
+            'groupmode' => SEPARATEGROUPS,
+        ]);
+
+        // Create three students:
+        // - author, enrolled in group A; and
+        // - recipient, enrolled in group B; and
+        // - other, enrolled in the course, but no groups.
+        list($author, $recipient, $otheruser) = $this->helper_create_users($course, 3);
+
+        // Create one teacher, not in any group and no accessallgroups capability.
+        list($teacher) = $this->helper_create_users($course, 1, 'teacher');
+
+        // Create one editing teacher, not in any group but with accessallgroups capability.
+        list($editingteacher) = $this->helper_create_users($course, 1, 'editingteacher');
+
+        $groupa = $this->getDataGenerator()->create_group(['courseid' => $course->id]);
+        $groupb = $this->getDataGenerator()->create_group(['courseid' => $course->id]);
+        $this->getDataGenerator()->create_group_member([
+            'groupid' => $groupa->id,
+            'userid' => $author->id,
+        ]);
+        $this->getDataGenerator()->create_group_member([
+            'groupid' => $groupb->id,
+            'userid' => $recipient->id,
+        ]);
+
+        // Post a discussion to the forum.
+        list($discussion, $post) = $this->helper_post_to_forum($forum, $author, [
+            'groupid' => $groupa->id,
+        ]);
+
+        // Only the author should receive.
+        $expect = [
+            'author' => (object) [
+                'userid' => $author->id,
+                'messages' => 1,
+            ],
+            'recipient' => (object) [
+                'userid' => $recipient->id,
+                'messages' => 0,
+            ],
+            'otheruser' => (object) [
+                'userid' => $otheruser->id,
+                'messages' => 0,
+            ],
+            'teacher' => (object) [
+                'userid' => $teacher->id,
+                'messages' => 0,
+            ],
+            'editingteacher' => (object) [
+                'userid' => $editingteacher->id,
+                'messages' => 1,
+            ],
+        ];
+        $this->queue_tasks_and_assert($expect);
+
+        // No notifications should be queued.
+        $this->send_notifications_and_assert($author, [$post]);
+        $this->send_notifications_and_assert($recipient, []);
+        $this->send_notifications_and_assert($otheruser, []);
+        $this->send_notifications_and_assert($teacher, []);
+        $this->send_notifications_and_assert($editingteacher, [$post]);
+    }
+
+    /**
+     * Ensure that posts written in a forum marked for visible groups includes notifications for the members of that
+     * group, and any user with accessallgroups.
+     */
+    public function test_visible_group() {
+        global $CFG, $DB;
+
+        $this->resetAfterTest(true);
+
+        // Create a course, with a forum.
+        $course = $this->getDataGenerator()->create_course();
+
+        $forum = $this->getDataGenerator()->create_module('forum', [
+            'course' => $course->id,
+            'forcesubscribe' => FORUM_INITIALSUBSCRIBE,
+            'groupmode' => VISIBLEGROUPS,
+        ]);
+
+        // Create three students:
+        // - author, enrolled in group A; and
+        // - recipient, enrolled in group B; and
+        // - other, enrolled in the course, but no groups.
+        list($author, $recipient, $otheruser) = $this->helper_create_users($course, 3);
+
+        // Create one teacher, not in any group and no accessallgroups capability.
+        list($teacher) = $this->helper_create_users($course, 1, 'teacher');
+
+        // Create one editing teacher, not in any group but with accessallgroups capability.
+        list($editingteacher) = $this->helper_create_users($course, 1, 'editingteacher');
+
+        $groupa = $this->getDataGenerator()->create_group(['courseid' => $course->id]);
+        $groupb = $this->getDataGenerator()->create_group(['courseid' => $course->id]);
+        $this->getDataGenerator()->create_group_member([
+            'groupid' => $groupa->id,
+            'userid' => $author->id,
+        ]);
+        $this->getDataGenerator()->create_group_member([
+            'groupid' => $groupb->id,
+            'userid' => $recipient->id,
+        ]);
+
+        // Post a discussion to the forum.
+        list($discussion, $post) = $this->helper_post_to_forum($forum, $author, [
+            'groupid' => $groupa->id,
+        ]);
+
+        // Only the author should receive.
+        $expect = [
+            'author' => (object) [
+                'userid' => $author->id,
+                'messages' => 1,
+            ],
+            'recipient' => (object) [
+                'userid' => $recipient->id,
+                'messages' => 0,
+            ],
+            'otheruser' => (object) [
+                'userid' => $otheruser->id,
+                'messages' => 0,
+            ],
+            'teacher' => (object) [
+                'userid' => $teacher->id,
+                'messages' => 0,
+            ],
+            'editingteacher' => (object) [
+                'userid' => $editingteacher->id,
+                'messages' => 1,
+            ],
+        ];
+        $this->queue_tasks_and_assert($expect);
+
+        // No notifications should be queued.
+        $this->send_notifications_and_assert($author, [$post]);
+        $this->send_notifications_and_assert($recipient, []);
+        $this->send_notifications_and_assert($otheruser, []);
+        $this->send_notifications_and_assert($teacher, []);
+        $this->send_notifications_and_assert($editingteacher, [$post]);
+    }
+}
index d233252..957a4ed 100644 (file)
 defined('MOODLE_INTERNAL') || die();
 
 global $CFG;
+require_once($CFG->dirroot . '/mod/forum/lib.php');
+require_once(__DIR__ . '/cron_trait.php');
+require_once(__DIR__ . '/generator_trait.php');
 
 class mod_forum_mail_testcase extends advanced_testcase {
+    // Make use of the cron tester trait.
+    use mod_forum_tests_cron_trait;
 
+    // Make use of the test generator trait.
+    use mod_forum_tests_generator_trait;
 
-    protected $helper;
+    /**
+     * @var \phpunit_message_sink
+     */
+    protected $messagesink;
+
+    /**
+     * @var \phpunit_mailer_sink
+     */
+    protected $mailsink;
 
     public function setUp() {
+        global $CFG;
+
         // We must clear the subscription caches. This has to be done both before each test, and after in case of other
         // tests using these functions.
         \mod_forum\subscriptions::reset_forum_cache();
         \mod_forum\subscriptions::reset_discussion_cache();
 
-        global $CFG;
-        require_once($CFG->dirroot . '/mod/forum/lib.php');
-
-        $helper = new stdClass();
-
         // Messaging is not compatible with transactions...
         $this->preventResetByRollback();
 
         // Catch all messages.
-        $helper->messagesink = $this->redirectMessages();
-        $helper->mailsink = $this->redirectEmails();
-
-        // Confirm that we have an empty message sink so far.
-        $messages = $helper->messagesink->get_messages();
-        $this->assertEquals(0, count($messages));
-
-        $messages = $helper->mailsink->get_messages();
-        $this->assertEquals(0, count($messages));
+        $this->messagesink = $this->redirectMessages();
+        $this->mailsink = $this->redirectEmails();
 
         // Forcibly reduce the maxeditingtime to a second in the past to
         // ensure that messages are sent out.
         $CFG->maxeditingtime = -1;
-
-        $this->helper = $helper;
     }
 
     public function tearDown() {
@@ -69,11 +72,13 @@ class mod_forum_mail_testcase extends advanced_testcase {
         // tests using these functions.
         \mod_forum\subscriptions::reset_forum_cache();
 
-        $this->helper->messagesink->clear();
-        $this->helper->messagesink->close();
+        $this->messagesink->clear();
+        $this->messagesink->close();
+        unset($this->messagesink);
 
-        $this->helper->mailsink->clear();
-        $this->helper->mailsink->close();
+        $this->mailsink->clear();
+        $this->mailsink->close();
+        unset($this->mailsink);
     }
 
     /**
@@ -93,166 +98,6 @@ class mod_forum_mail_testcase extends advanced_testcase {
         $record->id = $DB->update_record('messageinbound_handlers', $record);
     }
 
-    /**
-     * Helper to create the required number of users in the specified
-     * course.
-     * Users are enrolled as students.
-     *
-     * @param stdClass $course The course object
-     * @param integer $count The number of users to create
-     * @return array The users created
-     */
-    protected function helper_create_users($course, $count) {
-        $users = array();
-
-        for ($i = 0; $i < $count; $i++) {
-            $user = $this->getDataGenerator()->create_user();
-            $this->getDataGenerator()->enrol_user($user->id, $course->id);
-            $users[] = $user;
-        }
-
-        return $users;
-    }
-
-    /**
-     * Create a new discussion and post within the specified forum, as the
-     * specified author.
-     *
-     * @param stdClass $forum The forum to post in
-     * @param stdClass $author The author to post as
-     * @param array $fields any other fields in discussion (name, message, messageformat, ...)
-     * @param array An array containing the discussion object, and the post object
-     */
-    protected function helper_post_to_forum($forum, $author, $fields = array()) {
-        global $DB;
-        $generator = $this->getDataGenerator()->get_plugin_generator('mod_forum');
-
-        // Create a discussion in the forum, and then add a post to that discussion.
-        $record = (object)$fields;
-        $record->course = $forum->course;
-        $record->userid = $author->id;
-        $record->forum = $forum->id;
-        $discussion = $generator->create_discussion($record);
-
-        // Retrieve the post which was created by create_discussion.
-        $post = $DB->get_record('forum_posts', array('discussion' => $discussion->id));
-
-        return array($discussion, $post);
-    }
-
-    /**
-     * Update the post time for the specified post by $factor.
-     *
-     * @param stdClass $post The post to update
-     * @param int $factor The amount to update by
-     */
-    protected function helper_update_post_time($post, $factor) {
-        global $DB;
-
-        // Update the post to have a created in the past.
-        $DB->set_field('forum_posts', 'created', $post->created + $factor, array('id' => $post->id));
-    }
-
-    /**
-     * Update the subscription time for the specified user/discussion by $factor.
-     *
-     * @param stdClass $user The user to update
-     * @param stdClass $discussion The discussion to update for this user
-     * @param int $factor The amount to update by
-     */
-    protected function helper_update_subscription_time($user, $discussion, $factor) {
-        global $DB;
-
-        $sub = $DB->get_record('forum_discussion_subs', array('userid' => $user->id, 'discussion' => $discussion->id));
-
-        // Update the subscription to have a preference in the past.
-        $DB->set_field('forum_discussion_subs', 'preference', $sub->preference + $factor, array('id' => $sub->id));
-    }
-
-    /**
-     * Create a new post within an existing discussion, as the specified author.
-     *
-     * @param stdClass $forum The forum to post in
-     * @param stdClass $discussion The discussion to post in
-     * @param stdClass $author The author to post as
-     * @return stdClass The forum post
-     */
-    protected function helper_post_to_discussion($forum, $discussion, $author) {
-        global $DB;
-
-        $generator = $this->getDataGenerator()->get_plugin_generator('mod_forum');
-
-        // Add a post to the discussion.
-        $record = new stdClass();
-        $record->course = $forum->course;
-        $strre = get_string('re', 'forum');
-        $record->subject = $strre . ' ' . $discussion->subject;
-        $record->userid = $author->id;
-        $record->forum = $forum->id;
-        $record->discussion = $discussion->id;
-        $record->mailnow = 1;
-
-        $post = $generator->create_post($record);
-
-        return $post;
-    }
-
-    /**
-     * Run the forum cron, and check that the specified post was sent the
-     * specified number of times.
-     *
-     * @param stdClass $post The forum post object
-     * @param integer $expected The number of times that the post should have been sent
-     * @return array An array of the messages caught by the message sink
-     */
-    protected function helper_run_cron_check_count($post, $expected) {
-
-        // Clear the sinks before running cron.
-        $this->helper->messagesink->clear();
-        $this->helper->mailsink->clear();
-
-        // Cron daily uses mtrace, turn on buffering to silence output.
-        $this->expectOutputRegex("/{$expected} users were sent post {$post->id}, '{$post->subject}'/");
-        forum_cron();
-
-        // Now check the results in the message sink.
-        $messages = $this->helper->messagesink->get_messages();
-
-        // There should be the expected number of messages.
-        $this->assertEquals($expected, count($messages));
-
-        return $messages;
-    }
-
-    /**
-     * Run the forum cron, and check that the specified posts were sent the
-     * specified number of times.
-     *
-     * @param stdClass $post The forum post object
-     * @param integer $expected The number of times that the post should have been sent
-     * @return array An array of the messages caught by the message sink
-     */
-    protected function helper_run_cron_check_counts($posts, $expected) {
-
-        // Clear the sinks before running cron.
-        $this->helper->messagesink->clear();
-        $this->helper->mailsink->clear();
-
-        // Cron daily uses mtrace, turn on buffering to silence output.
-        foreach ($posts as $post) {
-            $this->expectOutputRegex("/{$post['count']} users were sent post {$post['id']}, '{$post['subject']}'/");
-        }
-        forum_cron();
-
-        // Now check the results in the message sink.
-        $messages = $this->helper->messagesink->get_messages();
-
-        // There should be the expected number of messages.
-        $this->assertEquals($expected, count($messages));
-
-        return $messages;
-    }
-
     public function test_cron_message_includes_courseid() {
         $this->resetAfterTest(true);
 
@@ -268,25 +113,22 @@ class mod_forum_mail_testcase extends advanced_testcase {
         // Post a discussion to the forum.
         list($discussion, $post) = $this->helper_post_to_forum($forum, $author);
 
-        // Run cron and check that \core\event\message_sent contains the course id.
-        // Close the message sink so that message_send is run.
-        $this->helper->messagesink->close();
-
-        // Catch just the cron events. For each message sent two events are fired:
-        // core\event\message_sent
-        // core\event\message_viewed.
-        $this->helper->eventsink = $this->redirectEvents();
-        $this->expectOutputRegex('/Processing user/');
-
-        forum_cron();
-
-        // Get the events and close the sink so that remaining events can be triggered.
-        $events = $this->helper->eventsink->get_events();
-        $this->helper->eventsink->close();
-
-        // Reset the message sink for other tests.
-        $this->helper->messagesink = $this->redirectMessages();
-        // Notification has been marked as read, so now first event should be a 'notification_viewed' one.
+        $expect = [
+            'author' => (object) [
+                'userid' => $author->id,
+                'messages' => 1,
+            ],
+            'recipient' => (object) [
+                'userid' => $recipient->id,
+                'messages' => 1,
+            ],
+        ];
+        $this->queue_tasks_and_assert($expect);
+
+        $this->messagesink->close();
+        $this->eventsink = $this->redirectEvents();
+        $this->send_notifications_and_assert($author, [$post]);
+        $events = $this->eventsink->get_events();
         $event = reset($events);
         $this->assertInstanceOf('\core\event\notification_viewed', $event);
 
@@ -294,6 +136,8 @@ class mod_forum_mail_testcase extends advanced_testcase {
         $event = $events[1];
         $this->assertInstanceOf('\core\event\notification_sent', $event);
         $this->assertEquals($course->id, $event->other['courseid']);
+
+        $this->send_notifications_and_assert($recipient, [$post]);
     }
 
     public function test_forced_subscription() {
@@ -311,31 +155,26 @@ class mod_forum_mail_testcase extends advanced_testcase {
         // Post a discussion to the forum.
         list($discussion, $post) = $this->helper_post_to_forum($forum, $author);
 
-        // We expect both users to receive this post.
-        $expected = 2;
-
-        // Run cron and check that the expected number of users received the notification.
-        $messages = $this->helper_run_cron_check_count($post, $expected);
-
-        $seenauthor = false;
-        $seenrecipient = false;
-        foreach ($messages as $message) {
-            // They should both be from our user.
-            $this->assertEquals($author->id, $message->useridfrom);
-
-            if ($message->useridto == $author->id) {
-                $seenauthor = true;
-            } else if ($message->useridto = $recipient->id) {
-                $seenrecipient = true;
-            }
-        }
-
-        // Check we saw messages for both users.
-        $this->assertTrue($seenauthor);
-        $this->assertTrue($seenrecipient);
+        $expect = [
+            (object) [
+                'userid' => $author->id,
+                'messages' => 1,
+            ],
+            (object) [
+                'userid' => $recipient->id,
+                'messages' => 1,
+            ],
+        ];
+        $this->queue_tasks_and_assert($expect);
+
+        $this->send_notifications_and_assert($author, [$post]);
+        $this->send_notifications_and_assert($recipient, [$post]);
     }
 
-    public function test_subscription_disabled() {
+    /**
+     * Ensure that for a forum with subscription disabled that standard users will not receive posts.
+     */
+    public function test_subscription_disabled_standard_users() {
         global $DB;
 
         $this->resetAfterTest(true);
@@ -352,50 +191,119 @@ class mod_forum_mail_testcase extends advanced_testcase {
         // Post a discussion to the forum.
         list($discussion, $post) = $this->helper_post_to_forum($forum, $author);
 
-        // We expect both users to receive this post.
-        $expected = 0;
-
         // Run cron and check that the expected number of users received the notification.
-        $messages = $this->helper_run_cron_check_count($post, $expected);
+        $expect = [
+            (object) [
+                'userid' => $author->id,
+                'messages' => 0,
+            ],
+            (object) [
+                'userid' => $recipient->id,
+                'messages' => 0,
+            ],
+        ];
+        $this->queue_tasks_and_assert($expect);
+
+        $this->send_notifications_and_assert($author, []);
+        $this->send_notifications_and_assert($recipient, []);
+    }
+
+    /**
+     * Ensure that for a forum with subscription disabled that a user subscribed to the forum will receive the post.
+     */
+    public function test_subscription_disabled_user_subscribed_forum() {
+        global $DB;
+
+        $this->resetAfterTest(true);
+
+        // Create a course, with a forum.
+        $course = $this->getDataGenerator()->create_course();
+
+        $options = array('course' => $course->id, 'forcesubscribe' => FORUM_DISALLOWSUBSCRIBE);
+        $forum = $this->getDataGenerator()->create_module('forum', $options);
+
+        // Create two users enrolled in the course as students.
+        list($author, $recipient) = $this->helper_create_users($course, 2);
 
         // A user with the manageactivities capability within the course can subscribe.
-        $expected = 1;
         $roleids = $DB->get_records_menu('role', null, '', 'shortname, id');
         assign_capability('moodle/course:manageactivities', CAP_ALLOW, $roleids['student'], context_course::instance($course->id));
+
+        // Suscribe the recipient only.
         \mod_forum\subscriptions::subscribe_user($recipient->id, $forum);
 
-        $this->assertEquals($expected, $DB->count_records('forum_subscriptions', array(
+        $this->assertEquals(1, $DB->count_records('forum_subscriptions', array(
             'userid'        => $recipient->id,
             'forum'         => $forum->id,
         )));
 
+        // Post a discussion to the forum.
+        list($discussion, $post) = $this->helper_post_to_forum($forum, $author);
+
         // Run cron and check that the expected number of users received the notification.
-        list($discussion, $post) = $this->helper_post_to_forum($forum, $recipient);
-        $messages = $this->helper_run_cron_check_count($post, $expected);
+        $expect = [
+            'author' => (object) [
+                'userid' => $author->id,
+            ],
+            'recipient' => (object) [
+                'userid' => $recipient->id,
+                'messages' => 1,
+            ],
+        ];
+        $this->queue_tasks_and_assert($expect);
+
+        $this->send_notifications_and_assert($author, []);
+        $this->send_notifications_and_assert($recipient, [$post]);
+    }
 
-        // Unsubscribe the user again.
-        \mod_forum\subscriptions::unsubscribe_user($recipient->id, $forum);
+    /**
+     * Ensure that for a forum with subscription disabled that a user subscribed to the discussion will receive the
+     * post.
+     */
+    public function test_subscription_disabled_user_subscribed_discussion() {
+        global $DB;
 
-        $expected = 0;
-        $this->assertEquals($expected, $DB->count_records('forum_subscriptions', array(
-            'userid'        => $recipient->id,
-            'forum'         => $forum->id,
-        )));
+        $this->resetAfterTest(true);
+
+        // Create a course, with a forum.
+        $course = $this->getDataGenerator()->create_course();
+
+        $options = array('course' => $course->id, 'forcesubscribe' => FORUM_DISALLOWSUBSCRIBE);
+        $forum = $this->getDataGenerator()->create_module('forum', $options);
+
+        // Create two users enrolled in the course as students.
+        list($author, $recipient) = $this->helper_create_users($course, 2);
+
+        // A user with the manageactivities capability within the course can subscribe.
+        $roleids = $DB->get_records_menu('role', null, '', 'shortname, id');
+        assign_capability('moodle/course:manageactivities', CAP_ALLOW, $roleids['student'], context_course::instance($course->id));
 
         // Run cron and check that the expected number of users received the notification.
         list($discussion, $post) = $this->helper_post_to_forum($forum, $author);
-        $messages = $this->helper_run_cron_check_count($post, $expected);
 
         // Subscribe the user to the discussion.
         \mod_forum\subscriptions::subscribe_user_to_discussion($recipient->id, $discussion);
         $this->helper_update_subscription_time($recipient, $discussion, -60);
 
-        $reply = $this->helper_post_to_discussion($forum, $discussion, $author);
-        $this->helper_update_post_time($reply, -30);
-
-        $messages = $this->helper_run_cron_check_count($reply, $expected);
+        // Run cron and check that the expected number of users received the notification.
+        $expect = [
+            'author' => (object) [
+                'userid' => $author->id,
+            ],
+            'recipient' => (object) [
+                'userid' => $recipient->id,
+                'messages' => 1,
+            ],
+        ];
+        $this->queue_tasks_and_assert($expect);
+
+        $this->send_notifications_and_assert($author, []);
+        $this->send_notifications_and_assert($recipient, [$post]);
     }
 
+    /**
+     * Ensure that for a forum with automatic subscription that users receive posts.
+     */
     public function test_automatic() {
         $this->resetAfterTest(true);
 
@@ -411,28 +319,20 @@ class mod_forum_mail_testcase extends advanced_testcase {
         // Post a discussion to the forum.
         list($discussion, $post) = $this->helper_post_to_forum($forum, $author);
 
-        // We expect both users to receive this post.
-        $expected = 2;
-
-        // Run cron and check that the expected number of users received the notification.
-        $messages = $this->helper_run_cron_check_count($post, $expected);
-
-        $seenauthor = false;
-        $seenrecipient = false;
-        foreach ($messages as $message) {
-            // They should both be from our user.
-            $this->assertEquals($author->id, $message->useridfrom);
-
-            if ($message->useridto == $author->id) {
-                $seenauthor = true;
-            } else if ($message->useridto = $recipient->id) {
-                $seenrecipient = true;
-            }
-        }
-
-        // Check we saw messages for both users.
-        $this->assertTrue($seenauthor);
-        $this->assertTrue($seenrecipient);
+        $expect = [
+            (object) [
+                'userid' => $author->id,
+                'messages' => 1,
+            ],
+            (object) [
+                'userid' => $recipient->id,
+                'messages' => 1,
+            ],
+        ];
+        $this->queue_tasks_and_assert($expect);
+
+        $this->send_notifications_and_assert($author, [$post]);
+        $this->send_notifications_and_assert($recipient, [$post]);
     }
 
     public function test_optional() {
@@ -450,11 +350,20 @@ class mod_forum_mail_testcase extends advanced_testcase {
         // Post a discussion to the forum.
         list($discussion, $post) = $this->helper_post_to_forum($forum, $author);
 
-        // We expect both users to receive this post.
-        $expected = 0;
-
-        // Run cron and check that the expected number of users received the notification.
-        $messages = $this->helper_run_cron_check_count($post, $expected);
+        $expect = [
+            (object) [
+                'userid' => $author->id,
+                'messages' => 0,
+            ],
+            (object) [
+                'userid' => $recipient->id,
+                'messages' => 0,
+            ],
+        ];
+        $this->queue_tasks_and_assert($expect);
+
+        $this->send_notifications_and_assert($author, []);
+        $this->send_notifications_and_assert($recipient, []);
     }
 
     public function test_automatic_with_unsubscribed_user() {
@@ -475,28 +384,20 @@ class mod_forum_mail_testcase extends advanced_testcase {
         // Post a discussion to the forum.
         list($discussion, $post) = $this->helper_post_to_forum($forum, $author);
 
-        // We expect only one user to receive this post.
-        $expected = 1;
-
-        // Run cron and check that the expected number of users received the notification.
-        $messages = $this->helper_run_cron_check_count($post, $expected);
-
-        $seenauthor = false;
-        $seenrecipient = false;
-        foreach ($messages as $message) {
-            // They should both be from our user.
-            $this->assertEquals($author->id, $message->useridfrom);
-
-            if ($message->useridto == $author->id) {
-                $seenauthor = true;
-            } else if ($message->useridto = $recipient->id) {
-                $seenrecipient = true;
-            }
-        }
-
-        // Check we only saw one user.
-        $this->assertFalse($seenauthor);
-        $this->assertTrue($seenrecipient);
+        $expect = [
+            (object) [
+                'userid' => $author->id,
+                'messages' => 0,
+            ],
+            (object) [
+                'userid' => $recipient->id,
+                'messages' => 1,
+            ],
+        ];
+        $this->queue_tasks_and_assert($expect);
+
+        $this->send_notifications_and_assert($author, []);
+        $this->send_notifications_and_assert($recipient, [$post]);
     }
 
     public function test_optional_with_subscribed_user() {
@@ -517,28 +418,20 @@ class mod_forum_mail_testcase extends advanced_testcase {
         // Post a discussion to the forum.
         list($discussion, $post) = $this->helper_post_to_forum($forum, $author);
 
-        // We expect only one user to receive this post.
-        $expected = 1;
-
-        // Run cron and check that the expected number of users received the notification.
-        $messages = $this->helper_run_cron_check_count($post, $expected);
-
-        $seenauthor = false;
-        $seenrecipient = false;
-        foreach ($messages as $message) {
-            // They should both be from our user.
-            $this->assertEquals($author->id, $message->useridfrom);
-
-            if ($message->useridto == $author->id) {
-                $seenauthor = true;
-            } else if ($message->useridto = $recipient->id) {
-                $seenrecipient = true;
-            }
-        }
-
-        // Check we only saw one user.
-        $this->assertFalse($seenauthor);
-        $this->assertTrue($seenrecipient);
+        $expect = [
+            (object) [
+                'userid' => $author->id,
+                'messages' => 0,
+            ],
+            (object) [
+                'userid' => $recipient->id,
+                'messages' => 1,
+            ],
+        ];
+        $this->queue_tasks_and_assert($expect);
+
+        $this->send_notifications_and_assert($author, []);
+        $this->send_notifications_and_assert($recipient, [$post]);
     }
 
     public function test_automatic_with_unsubscribed_discussion() {
@@ -562,28 +455,20 @@ class mod_forum_mail_testcase extends advanced_testcase {
         $this->assertFalse(\mod_forum\subscriptions::is_subscribed($author->id, $forum, $discussion->id));
         $this->assertTrue(\mod_forum\subscriptions::is_subscribed($recipient->id, $forum, $discussion->id));
 
-        // We expect only one user to receive this post.
-        $expected = 1;
-
-        // Run cron and check that the expected number of users received the notification.
-        $messages = $this->helper_run_cron_check_count($post, $expected);
-
-        $seenauthor = false;
-        $seenrecipient = false;
-        foreach ($messages as $message) {
-            // They should both be from our user.
-            $this->assertEquals($author->id, $message->useridfrom);
-
-            if ($message->useridto == $author->id) {
-                $seenauthor = true;
-            } else if ($message->useridto = $recipient->id) {
-                $seenrecipient = true;
-            }
-        }
-
-        // Check we only saw one user.
-        $this->assertFalse($seenauthor);
-        $this->assertTrue($seenrecipient);
+        $expect = [
+            (object) [
+                'userid' => $author->id,
+                'messages' => 0,
+            ],
+            (object) [
+                'userid' => $recipient->id,
+                'messages' => 1,
+            ],
+        ];
+        $this->queue_tasks_and_assert($expect);
+
+        $this->send_notifications_and_assert($author, []);
+        $this->send_notifications_and_assert($recipient, [$post]);
     }
 
     public function test_optional_with_subscribed_discussion() {
@@ -608,37 +493,40 @@ class mod_forum_mail_testcase extends advanced_testcase {
 
         // Initially we don't expect any user to receive this post as you cannot subscribe to a discussion until after
         // you have read it.
-        $expected = 0;
-
-        // Run cron and check that the expected number of users received the notification.
-        $messages = $this->helper_run_cron_check_count($post, $expected);
+        $expect = [
+            (object) [
+                'userid' => $author->id,
+                'messages' => 0,
+            ],
+            (object) [
+                'userid' => $recipient->id,
+                'messages' => 0,
+            ],
+        ];
+        $this->queue_tasks_and_assert($expect);
+
+        $this->send_notifications_and_assert($author, []);
+        $this->send_notifications_and_assert($recipient, []);
 
         // Have a user reply to the discussion.
         $reply = $this->helper_post_to_discussion($forum, $discussion, $author);
         $this->helper_update_post_time($reply, -30);
 
         // We expect only one user to receive this post.
-        $expected = 1;
-
-        // Run cron and check that the expected number of users received the notification.
-        $messages = $this->helper_run_cron_check_count($reply, $expected);
-
-        $seenauthor = false;
-        $seenrecipient = false;
-        foreach ($messages as $message) {
-            // They should both be from our user.
-            $this->assertEquals($author->id, $message->useridfrom);
-
-            if ($message->useridto == $author->id) {
-                $seenauthor = true;
-            } else if ($message->useridto = $recipient->id) {
-                $seenrecipient = true;
-            }
-        }
-
-        // Check we only saw one user.
-        $this->assertFalse($seenauthor);
-        $this->assertTrue($seenrecipient);
+        $expect = [
+            (object) [
+                'userid' => $author->id,
+                'messages' => 0,
+            ],
+            (object) [
+                'userid' => $recipient->id,
+                'messages' => 1,
+            ],
+        ];
+        $this->queue_tasks_and_assert($expect);
+
+        $this->send_notifications_and_assert($author, []);
+        $this->send_notifications_and_assert($recipient, [$reply]);
     }
 
     public function test_automatic_with_subscribed_discussion_in_unsubscribed_forum() {
@@ -664,56 +552,39 @@ class mod_forum_mail_testcase extends advanced_testcase {
         \mod_forum\subscriptions::subscribe_user_to_discussion($author->id, $discussion);
         $this->helper_update_subscription_time($author, $discussion, -60);
 
-        // We expect just the user subscribed to the forum to receive this post at the moment as the discussion
-        // subscription time is after the post time.
-        $expected = 1;
-
-        // Run cron and check that the expected number of users received the notification.
-        $messages = $this->helper_run_cron_check_count($post, $expected);
-
-        $seenauthor = false;
-        $seenrecipient = false;
-        foreach ($messages as $message) {
-            // They should both be from our user.
-            $this->assertEquals($author->id, $message->useridfrom);
-
-            if ($message->useridto == $author->id) {
-                $seenauthor = true;
-            } else if ($message->useridto = $recipient->id) {
-                $seenrecipient = true;
-            }
-        }
-
-        // Check we only saw one user.
-        $this->assertFalse($seenauthor);
-        $this->assertTrue($seenrecipient);
+        $expect = [
+            (object) [
+                'userid' => $author->id,
+                'messages' => 0,
+            ],
+            (object) [
+                'userid' => $recipient->id,
+                'messages' => 1,
+            ],
+        ];
+        $this->queue_tasks_and_assert($expect);
+
+        $this->send_notifications_and_assert($author, []);
+        $this->send_notifications_and_assert($recipient, [$post]);
 
         // Now post a reply to the original post.
         $reply = $this->helper_post_to_discussion($forum, $discussion, $author);
         $this->helper_update_post_time($reply, -30);
 
-        // We expect two users to receive this post.
-        $expected = 2;
-
-        // Run cron and check that the expected number of users received the notification.
-        $messages = $this->helper_run_cron_check_count($reply, $expected);
-
-        $seenauthor = false;
-        $seenrecipient = false;
-        foreach ($messages as $message) {
-            // They should both be from our user.
-            $this->assertEquals($author->id, $message->useridfrom);
-
-            if ($message->useridto == $author->id) {
-                $seenauthor = true;
-            } else if ($message->useridto = $recipient->id) {
-                $seenrecipient = true;
-            }
-        }
-
-        // Check we saw both users.
-        $this->assertTrue($seenauthor);
-        $this->assertTrue($seenrecipient);
+        $expect = [
+            (object) [
+                'userid' => $author->id,
+                'messages' => 1,
+            ],
+            (object) [
+                'userid' => $recipient->id,
+                'messages' => 1,
+            ],
+        ];
+        $this->queue_tasks_and_assert($expect);
+
+        $this->send_notifications_and_assert($author, [$reply]);
+        $this->send_notifications_and_assert($recipient, [$reply]);
     }
 
     public function test_optional_with_unsubscribed_discussion_in_subscribed_forum() {
@@ -738,10 +609,20 @@ class mod_forum_mail_testcase extends advanced_testcase {
         \mod_forum\subscriptions::unsubscribe_user_from_discussion($recipient->id, $discussion);
 
         // We don't expect any users to receive this post.
-        $expected = 0;
-
-        // Run cron and check that the expected number of users received the notification.
-        $messages = $this->helper_run_cron_check_count($post, $expected);
+        $expect = [
+            (object) [
+                'userid' => $author->id,
+                'messages' => 0,
+            ],
+            (object) [
+                'userid' => $recipient->id,
+                'messages' => 0,
+            ],
+        ];
+        $this->queue_tasks_and_assert($expect);
+
+        $this->send_notifications_and_assert($author, []);
+        $this->send_notifications_and_assert($recipient, []);
     }
 
     /**
@@ -780,16 +661,15 @@ class mod_forum_mail_testcase extends advanced_testcase {
         $reply = $this->helper_post_to_discussion($forum, $discussion, $author);
         $this->helper_update_post_time($reply, -30);
 
-        $expectedmessages[] = array(
-            'id' => $reply->id,
-            'subject' => $reply->subject,
-            'count' => 1,
-        );
-
-        $expectedcount = 1;
+        $expect = [
+            (object) [
+                'userid' => $author->id,
+                'messages' => 1,
+            ],
+        ];
+        $this->queue_tasks_and_assert($expect);
 
-        // Run cron and check that the expected number of users received the notification.
-        $messages = $this->helper_run_cron_check_counts($expectedmessages, $expectedcount);
+        $this->send_notifications_and_assert($author, [$reply]);
     }
 
     public function test_forum_message_inbound_multiple_posts() {
@@ -809,23 +689,21 @@ class mod_forum_mail_testcase extends advanced_testcase {
         list($discussion, $post) = $this->helper_post_to_forum($forum, $author);
         $this->helper_update_post_time($post, -90);
 
-        $expectedmessages[] = array(
+        $expectedmessages[] = (object) [
             'id' => $post->id,
             'subject' => $post->subject,
             'count' => 0,
-        );
+        ];
 
         // Then post a reply to the first discussion.
         $reply = $this->helper_post_to_discussion($forum, $discussion, $author);
         $this->helper_update_post_time($reply, -60);
 
-        $expectedmessages[] = array(
+        $expectedmessages[] = (object) [
             'id' => $reply->id,
             'subject' => $reply->subject,
             'count' => 1,
-        );
-
-        $expectedcount = 2;
+        ];
 
         // Ensure that messageinbound is enabled and configured for the forum handler.
         $this->helper_spoof_message_inbound_setup();
@@ -836,19 +714,22 @@ class mod_forum_mail_testcase extends advanced_testcase {
 
         // Run cron and check that the expected number of users received the notification.
         // Clear the mailsink, and close the messagesink.
-        $this->helper->mailsink->clear();
-        $this->helper->messagesink->close();
+        $this->mailsink->clear();
+        $this->messagesink->close();
 
-        // Cron daily uses mtrace, turn on buffering to silence output.
-        foreach ($expectedmessages as $post) {
-            $this->expectOutputRegex("/{$post['count']} users were sent post {$post['id']}, '{$post['subject']}'/");
-        }
+        $expect = [
+            'author' => (object) [
+                'userid' => $author->id,
+                'messages' => count($expectedmessages),
+            ],
+        ];
+        $this->queue_tasks_and_assert($expect);
 
-        forum_cron();
-        $messages = $this->helper->mailsink->get_messages();
+        $this->send_notifications_and_assert($author, $expectedmessages);
+        $messages = $this->mailsink->get_messages();
 
         // There should be the expected number of messages.
-        $this->assertEquals($expectedcount, count($messages));
+        $this->assertEquals(2, count($messages));
 
         foreach ($messages as $message) {
             $this->assertRegExp('/Reply-To: moodlemoodle123\+[^@]*@example.com/', $message->header);
@@ -874,7 +755,16 @@ class mod_forum_mail_testcase extends advanced_testcase {
         list($discussion, $post) = $this->helper_post_to_forum($forum, $author, array('name' => $subject));
 
         // Run cron and check that the expected number of users received the notification.
-        $messages = $this->helper_run_cron_check_count($post, 1);
+        $expect = [
+            'author' => (object) [
+                'userid' => $author->id,
+                'messages' => 1,
+            ],
+        ];
+        $this->queue_tasks_and_assert($expect);
+
+        $this->send_notifications_and_assert($author, [$post]);
+        $messages = $this->messagesink->get_messages();
         $message = reset($messages);
         $this->assertEquals($author->id, $message->useridfrom);
         $this->assertEquals($expectedsubject, $message->subject);
@@ -898,13 +788,44 @@ class mod_forum_mail_testcase extends advanced_testcase {
 
         // New posts should not have Re: in the subject.
         list($discussion, $post) = $this->helper_post_to_forum($forum, $author);
-        $messages = $this->helper_run_cron_check_count($post, 2);
+        $expect = [
+            'author' => (object) [
+                'userid' => $author->id,
+                'messages' => 1,
+            ],
+            'commenter' => (object) [
+                'userid' => $commenter->id,
+                'messages' => 1,
+            ],
+        ];
+        $this->queue_tasks_and_assert($expect);
+
+        $this->send_notifications_and_assert($author, [$post]);
+        $this->send_notifications_and_assert($commenter, [$post]);
+        $messages = $this->messagesink->get_messages();
         $this->assertNotContains($strre, $messages[0]->subject);
+        $this->messagesink->clear();
 
         // Replies should have Re: in the subject.
         $reply = $this->helper_post_to_discussion($forum, $discussion, $commenter);
-        $messages = $this->helper_run_cron_check_count($reply, 2);
+
+        $expect = [
+            'author' => (object) [
+                'userid' => $author->id,
+                'messages' => 1,
+            ],
+            'commenter' => (object) [
+                'userid' => $commenter->id,
+                'messages' => 1,
+            ],
+        ];
+        $this->queue_tasks_and_assert($expect);
+
+        $this->send_notifications_and_assert($commenter, [$reply]);
+        $this->send_notifications_and_assert($author, [$reply]);
+        $messages = $this->messagesink->get_messages();
         $this->assertContains($strre, $messages[0]->subject);
+        $this->assertContains($strre, $messages[1]->subject);
     }
 
     /**
@@ -1127,17 +1048,21 @@ class mod_forum_mail_testcase extends advanced_testcase {
 
         // Clear the mailsink and close the messagesink.
         // (surely setup should provide us this cleared but...)
-        $this->helper->mailsink->clear();
-        $this->helper->messagesink->close();
+        $this->mailsink->clear();
+        $this->messagesink->close();
 
-        // Capture and silence cron output, verifying contents.
-        foreach ($posts as $post) {
-            $this->expectOutputRegex("/1 users were sent post {$post->id}, '{$post->subject}'/");
-        }
-        forum_cron(); // It's really annoying that we have to run cron to test this.
+        $expect = [
+            'author' => (object) [
+                'userid' => $user->id,
+                'messages' => count($posts),
+            ],
+        ];
+        $this->queue_tasks_and_assert($expect);
+
+        $this->send_notifications_and_assert($user, $posts);
 
         // Get the mails.
-        $mails = $this->helper->mailsink->get_messages();
+        $mails = $this->mailsink->get_messages();
 
         // Start testing the expectations.
         $expectations = $data['expectations'];
@@ -1182,7 +1107,301 @@ class mod_forum_mail_testcase extends advanced_testcase {
                 }
             }
         }
+
         // Finished, there should not be remaining expectations.
         $this->assertCount(0, $expectations);
     }
+
+    /**
+     * Ensure that posts already mailed are not re-sent.
+     */
+    public function test_already_mailed() {
+        global $DB;
+
+        $this->resetAfterTest(true);
+
+        // Create a course, with a forum.
+        $course = $this->getDataGenerator()->create_course();
+
+        $options = array('course' => $course->id, 'forcesubscribe' => FORUM_INITIALSUBSCRIBE);
+        $forum = $this->getDataGenerator()->create_module('forum', $options);
+
+        // Create two users enrolled in the course as students.
+        list($author, $recipient) = $this->helper_create_users($course, 2);
+
+        // Post a discussion to the forum.
+        list($discussion, $post) = $this->helper_post_to_forum($forum, $author);
+        $DB->set_field('forum_posts', 'mailed', 1);
+
+        // No posts shoudl be considered.
+        $this->queue_tasks_and_assert([]);
+
+        // No notifications should be queued.
+        $this->send_notifications_and_assert($author, []);
+        $this->send_notifications_and_assert($recipient, []);
+    }
+
+    /**
+     * Ensure that posts marked mailnow are not suspect to the maxeditingtime.
+     */
+    public function test_mailnow() {
+        global $CFG, $DB;
+
+        // Update the maxeditingtime to 1 day so that posts won't be sent.
+        $CFG->maxeditingtime = DAYSECS;
+
+        $this->resetAfterTest(true);
+
+        // Create a course, with a forum.
+        $course = $this->getDataGenerator()->create_course();
+
+        $options = array('course' => $course->id, 'forcesubscribe' => FORUM_INITIALSUBSCRIBE);
+        $forum = $this->getDataGenerator()->create_module('forum', $options);
+
+        // Create two users enrolled in the course as students.
+        list($author, $recipient) = $this->helper_create_users($course, 2);
+
+        // Post a discussion to the forum.
+        list($discussion, $post) = $this->helper_post_to_forum($forum, $author);
+
+        // Post a discussion to the forum.
+        list($discussion, $postmailednow) = $this->helper_post_to_forum($forum, $author, ['mailnow' => 1]);
+
+        // Only the mailnow post should be considered.
+        $expect = [
+            'author' => (object) [
+                'userid' => $author->id,
+                'messages' => 1,
+            ],
+            'recipient' => (object) [
+                'userid' => $recipient->id,
+                'messages' => 1,
+            ],
+        ];
+        $this->queue_tasks_and_assert($expect);
+
+        // No notifications should be queued.
+        $this->send_notifications_and_assert($author, [$postmailednow]);
+        $this->send_notifications_and_assert($recipient, [$postmailednow]);
+    }
+
+    /**
+     * Ensure that if a user has no permission to view a post, then it is not sent.
+     */
+    public function test_access_coursemodule_hidden() {
+        global $CFG, $DB;
+
+        $this->resetAfterTest(true);
+
+        // Create a course, with a forum.
+        $course = $this->getDataGenerator()->create_course();
+
+        $options = array('course' => $course->id, 'forcesubscribe' => FORUM_INITIALSUBSCRIBE);
+        $forum = $this->getDataGenerator()->create_module('forum', $options);
+
+        // Create two users enrolled in the course as students.
+        list($author, $recipient) = $this->helper_create_users($course, 2);
+
+        // Create one users enrolled in the course as an editing teacher.
+        list($editor) = $this->helper_create_users($course, 1, 'editingteacher');
+
+        // Post a discussion to the forum.
+        list($discussion, $post) = $this->helper_post_to_forum($forum, $author);
+
+        // Hide the coursemodule.
+        set_coursemodule_visible($forum->cmid, 0);
+
+        // Only the mailnow post should be considered.
+        $expect = [
+            'author' => (object) [
+                'userid' => $author->id,
+                'messages' => 0,
+            ],
+            'recipient' => (object) [
+                'userid' => $recipient->id,
+                'messages' => 0,
+            ],
+            'editor' => (object) [
+                'userid' => $editor->id,
+                'messages' => 1,
+            ],
+        ];
+        $this->queue_tasks_and_assert($expect);
+
+        // No notifications should be queued.
+        $this->send_notifications_and_assert($author, []);
+        $this->send_notifications_and_assert($recipient, []);
+        $this->send_notifications_and_assert($editor, [$post], true);
+    }
+
+    /**
+     * Ensure that if a user loses permission to view a post after it is queued, that it is not sent.
+     */
+    public function test_access_coursemodule_hidden_after_queue() {
+        global $CFG, $DB;
+
+        $this->resetAfterTest(true);
+
+        // Create a course, with a forum.
+        $course = $this->getDataGenerator()->create_course();
+
+        $options = array('course' => $course->id, 'forcesubscribe' => FORUM_INITIALSUBSCRIBE);
+        $forum = $this->getDataGenerator()->create_module('forum', $options);
+
+        // Create two users enrolled in the course as students.
+        list($author, $recipient) = $this->helper_create_users($course, 2);
+
+        // Create one users enrolled in the course as an editing teacher.
+        list($editor) = $this->helper_create_users($course, 1, 'editingteacher');
+
+        // Post a discussion to the forum.
+        list($discussion, $post) = $this->helper_post_to_forum($forum, $author);
+
+        // Only the mailnow post should be considered.
+        $expect = [
+            'author' => (object) [
+                'userid' => $author->id,
+                'messages' => 1,
+            ],
+            'recipient' => (object) [
+                'userid' => $recipient->id,
+                'messages' => 1,
+            ],
+            'editor' => (object) [
+                'userid' => $editor->id,
+                'messages' => 1,
+            ],
+        ];
+        $this->queue_tasks_and_assert($expect);
+
+        // Hide the coursemodule.
+        set_coursemodule_visible($forum->cmid, 0);
+
+        // No notifications should be queued for the students.
+        $this->send_notifications_and_assert($author, [], true);
+        $this->send_notifications_and_assert($recipient, [], true);
+
+        // The editing teacher should still receive the post.
+        $this->send_notifications_and_assert($editor, [$post]);
+    }
+
+    /**
+     * Ensure that messages are not sent until the timestart.
+     */
+    public function test_access_before_timestart() {
+        global $CFG, $DB;
+
+        $this->resetAfterTest(true);
+
+        // Create a course, with a forum.
+        $course = $this->getDataGenerator()->create_course();
+
+        $options = array('course' => $course->id, 'forcesubscribe' => FORUM_INITIALSUBSCRIBE);
+        $forum = $this->getDataGenerator()->create_module('forum', $options);
+
+        // Create two users enrolled in the course as students.
+        list($author, $recipient) = $this->helper_create_users($course, 2);
+
+        // Create one users enrolled in the course as an editing teacher.
+        list($editor) = $this->helper_create_users($course, 1, 'editingteacher');
+
+        // Post a discussion to the forum.
+        list($discussion, $post) = $this->helper_post_to_forum($forum, $author);
+
+        // Update the discussion to have a timestart in the future.
+        $DB->set_field('forum_discussions', 'timestart', time() + DAYSECS);
+
+        // None should be sent.
+        $this->queue_tasks_and_assert([]);
+
+        // No notifications should be queued for any user.
+        $this->send_notifications_and_assert($author, []);
+        $this->send_notifications_and_assert($recipient, []);
+        $this->send_notifications_and_assert($editor, []);
+
+        // Update the discussion to have a timestart in the past.
+        $DB->set_field('forum_discussions', 'timestart', time() - DAYSECS);
+
+        // Now should be sent to all.
+        $expect = [
+            'author' => (object) [
+                'userid' => $author->id,
+                'messages' => 1,
+            ],
+            'recipient' => (object) [
+                'userid' => $recipient->id,
+                'messages' => 1,
+            ],
+            'editor' => (object) [
+                'userid' => $editor->id,
+                'messages' => 1,
+            ],
+        ];
+        $this->queue_tasks_and_assert($expect);
+
+        // No notifications should be queued for any user.
+        $this->send_notifications_and_assert($author, [$post]);
+        $this->send_notifications_and_assert($recipient, [$post]);
+        $this->send_notifications_and_assert($editor, [$post]);
+    }
+
+    /**
+     * Ensure that messages are not sent after the timeend.
+     */
+    public function test_access_after_timeend() {
+        global $CFG, $DB;
+
+        $this->resetAfterTest(true);
+
+        // Create a course, with a forum.
+        $course = $this->getDataGenerator()->create_course();
+
+        $options = array('course' => $course->id, 'forcesubscribe' => FORUM_INITIALSUBSCRIBE);
+        $forum = $this->getDataGenerator()->create_module('forum', $options);
+
+        // Create two users enrolled in the course as students.
+        list($author, $recipient) = $this->helper_create_users($course, 2);
+
+        // Create one users enrolled in the course as an editing teacher.
+        list($editor) = $this->helper_create_users($course, 1, 'editingteacher');
+
+        // Post a discussion to the forum.
+        list($discussion, $post) = $this->helper_post_to_forum($forum, $author);
+
+        // Update the discussion to have a timestart in the past.
+        $DB->set_field('forum_discussions', 'timeend', time() - DAYSECS);
+
+        // None should be sent.
+        $this->queue_tasks_and_assert([]);
+
+        // No notifications should be queued for any user.
+        $this->send_notifications_and_assert($author, []);
+        $this->send_notifications_and_assert($recipient, []);
+        $this->send_notifications_and_assert($editor, []);
+
+        // Update the discussion to have a timestart in the past.
+        $DB->set_field('forum_discussions', 'timeend', time() + DAYSECS);
+
+        // Now should be sent to all.
+        $expect = [
+            'author' => (object) [
+                'userid' => $author->id,
+                'messages' => 1,
+            ],
+            'recipient' => (object) [
+                'userid' => $recipient->id,
+                'messages' => 1,
+            ],
+            'editor' => (object) [
+                'userid' => $editor->id,
+                'messages' => 1,
+            ],
+        ];
+        $this->queue_tasks_and_assert($expect);
+
+        // No notifications should be queued for any user.
+        $this->send_notifications_and_assert($author, [$post]);
+        $this->send_notifications_and_assert($recipient, [$post]);
+        $this->send_notifications_and_assert($editor, [$post]);
+    }
 }
index 8dbb5e1..86a3613 100644 (file)
 
 defined('MOODLE_INTERNAL') || die();
 
-global $CFG;
+require_once(__DIR__ . '/cron_trait.php');
+require_once(__DIR__ . '/generator_trait.php');
 
 class mod_forum_maildigest_testcase extends advanced_testcase {
 
-    /**
-     * Keep track of the message and mail sinks that we set up for each
-     * test.
-     *
-     * @var stdClass $helper
-     */
-    protected $helper;
+    // Make use of the cron tester trait.
+    use mod_forum_tests_cron_trait;
+
+    // Make use of the test generator trait.
+    use mod_forum_tests_generator_trait;
 
     /**
      * Set up message and mail sinks, and set up other requirements for the
@@ -45,20 +44,18 @@ class mod_forum_maildigest_testcase extends advanced_testcase {
     public function setUp() {
         global $CFG;
 
-        $this->helper = new stdClass();
-
         // Messaging is not compatible with transactions...
         $this->preventResetByRollback();
 
         // Catch all messages
-        $this->helper->messagesink = $this->redirectMessages();
-        $this->helper->mailsink = $this->redirectEmails();
+        $this->messagesink = $this->redirectMessages();
+        $this->mailsink = $this->redirectEmails();
 
         // Confirm that we have an empty message sink so far.
-        $messages = $this->helper->messagesink->get_messages();
+        $messages = $this->messagesink->get_messages();
         $this->assertEquals(0, count($messages));
 
-        $messages = $this->helper->mailsink->get_messages();
+        $messages = $this->mailsink->get_messages();
         $this->assertEquals(0, count($messages));
 
         // Tell Moodle that we've not sent any digest messages out recently.
@@ -82,11 +79,11 @@ class mod_forum_maildigest_testcase extends advanced_testcase {
      * Clear the message sinks set up in this test.
      */
     public function tearDown() {
-        $this->helper->messagesink->clear();
-        $this->helper->messagesink->close();
+        $this->messagesink->clear();
+        $this->messagesink->close();
 
-        $this->helper->mailsink->clear();
-        $this->helper->mailsink->close();
+        $this->mailsink->clear();
+        $this->mailsink->close();
     }
 
     /**
@@ -131,52 +128,6 @@ class mod_forum_maildigest_testcase extends advanced_testcase {
         return $return;
     }
 
-    /**
-     * Helper to falsify all forum post records for a digest run.
-     */
-    protected function helper_force_digest_mail_times() {
-        global $CFG, $DB;
-        // Fake all of the post editing times because digests aren't sent until
-        // the start of an hour where the modification time on the message is before
-        // the start of that hour
-        $sitetimezone = core_date::get_server_timezone();
-        $digesttime = usergetmidnight(time(), $sitetimezone) + ($CFG->digestmailtime * 3600) - (60 * 60);
-        $DB->set_field('forum_posts', 'modified', $digesttime, array('mailed' => 0));
-        $DB->set_field('forum_posts', 'created', $digesttime, array('mailed' => 0));
-    }
-
-    /**
-     * Run the forum cron, and check that the specified post was sent the
-     * specified number of times.
-     *
-     * @param integer $expected The number of times that the post should have been sent
-     * @param integer $individualcount The number of individual messages sent
-     * @param integer $digestcount The number of digest messages sent
-     */
-    protected function helper_run_cron_check_count($expected, $individualcount, $digestcount) {
-        if ($expected === 0) {
-            $this->expectOutputRegex('/(Email digests successfully sent to .* users.){0}/');
-        } else {
-            $this->expectOutputRegex("/Email digests successfully sent to {$expected} users/");
-        }
-        forum_cron();
-
-        // Now check the results in the message sink.
-        $messages = $this->helper->messagesink->get_messages();
-
-        $counts = (object) array('digest' => 0, 'individual' => 0);
-        foreach ($messages as $message) {
-            if (strpos($message->subject, 'forum digest') !== false) {
-                $counts->digest++;
-            } else {
-                $counts->individual++;
-            }
-        }
-
-        $this->assertEquals($digestcount, $counts->digest);
-        $this->assertEquals($individualcount, $counts->individual);
-    }
-
     public function test_set_maildigest() {
         global $DB;
 
@@ -302,10 +253,9 @@ class mod_forum_maildigest_testcase extends advanced_testcase {
 
         $this->resetAfterTest(true);
 
-        $this->helper_force_digest_mail_times();
-
         // Initially the forum cron should generate no messages as we've made no posts.
-        $this->helper_run_cron_check_count(0, 0, 0);
+        $expect = [];
+        $this->queue_tasks_and_assert($expect);
     }
 
     /**
@@ -324,27 +274,19 @@ class mod_forum_maildigest_testcase extends advanced_testcase {
         $forum1 = $userhelper->forums->forum1;
         $forum2 = $userhelper->forums->forum2;
 
-        // Add some discussions to the forums.
-        $record = new stdClass();
-        $record->course = $course1->id;
-        $record->userid = $user->id;
-        $record->mailnow = 1;
-
         // Add 5 discussions to forum 1.
-        $record->forum = $forum1->id;
+        $posts = [];
         for ($i = 0; $i < 5; $i++) {
-            $this->getDataGenerator()->get_plugin_generator('mod_forum')->create_discussion($record);
+            list($discussion, $post) = $this->helper_post_to_forum($forum1, $user, ['mailnow' => 1]);
+            $posts[] = $post;
         }
 
         // Add 5 discussions to forum 2.
-        $record->forum = $forum2->id;
         for ($i = 0; $i < 5; $i++) {
-            $this->getDataGenerator()->get_plugin_generator('mod_forum')->create_discussion($record);
+            list($discussion, $post) = $this->helper_post_to_forum($forum2, $user, ['mailnow' => 1]);
+            $posts[] = $post;
         }
 
-        // Ensure that the creation times mean that the messages will be sent.
-        $this->helper_force_digest_mail_times();
-
         // Set the tested user's default maildigest setting.
         $DB->set_field('user', 'maildigest', 0, array('id' => $user->id));
 
@@ -355,7 +297,16 @@ class mod_forum_maildigest_testcase extends advanced_testcase {
         forum_set_user_maildigest($forum2, -1, $user);
 
         // No digests mails should be sent, but 10 forum mails will be sent.
-        $this->helper_run_cron_check_count(0, 10, 0);
+        $expect = [
+            (object) [
+                'userid' => $user->id,
+                'messages' => 10,
+                'digests' => 0,
+            ],
+        ];
+        $this->queue_tasks_and_assert($expect);
+
+        $this->send_notifications_and_assert($user, $posts);
     }
 
     /**
@@ -373,28 +324,20 @@ class mod_forum_maildigest_testcase extends advanced_testcase {
         $course1 = $userhelper->courses->course1;
         $forum1 = $userhelper->forums->forum1;
         $forum2 = $userhelper->forums->forum2;
-
-        // Add a discussion to the forums.
-        $record = new stdClass();
-        $record->course = $course1->id;
-        $record->userid = $user->id;
-        $record->mailnow = 1;
+        $posts = [];
 
         // Add 5 discussions to forum 1.
-        $record->forum = $forum1->id;
         for ($i = 0; $i < 5; $i++) {
-            $this->getDataGenerator()->get_plugin_generator('mod_forum')->create_discussion($record);
+            list($discussion, $post) = $this->helper_post_to_forum($forum1, $user, ['mailnow' => 1]);
+            $posts[] = $post;
         }
 
         // Add 5 discussions to forum 2.
-        $record->forum = $forum2->id;
         for ($i = 0; $i < 5; $i++) {
-            $this->getDataGenerator()->get_plugin_generator('mod_forum')->create_discussion($record);
+            list($discussion, $post) = $this->helper_post_to_forum($forum2, $user, ['mailnow' => 1]);
+            $posts[] = $post;
         }
 
-        // Ensure that the creation times mean that the messages will be sent.
-        $this->helper_force_digest_mail_times();
-
         // Set the tested user's default maildigest setting.
         $DB->set_field('user', 'maildigest', 1, array('id' => $user->id));
 
@@ -404,8 +347,17 @@ class mod_forum_maildigest_testcase extends advanced_testcase {
         // Set the maildigest preference for forum2 to default.
         forum_set_user_maildigest($forum2, -1, $user);
 
-        // One digest mail should be sent, with no notifications, and one e-mail.
-        $this->helper_run_cron_check_count(1, 0, 1);
+        // No digests mails should be sent, but 10 forum mails will be sent.
+        $expect = [
+            (object) [
+                'userid' => $user->id,
+                'messages' => 0,
+                'digests' => 1,
+            ],
+        ];
+        $this->queue_tasks_and_assert($expect);
+
+        $this->send_digests_and_assert($user, $posts);
     }
 
     /**
@@ -424,28 +376,21 @@ class mod_forum_maildigest_testcase extends advanced_testcase {
         $course1 = $userhelper->courses->course1;
         $forum1 = $userhelper->forums->forum1;
         $forum2 = $userhelper->forums->forum2;
-
-        // Add a discussion to the forums.
-        $record = new stdClass();
-        $record->course = $course1->id;
-        $record->userid = $user->id;
-        $record->mailnow = 1;
+        $posts = [];
+        $digests = [];
 
         // Add 5 discussions to forum 1.
-        $record->forum = $forum1->id;
         for ($i = 0; $i < 5; $i++) {
-            $this->getDataGenerator()->get_plugin_generator('mod_forum')->create_discussion($record);
+            list($discussion, $post) = $this->helper_post_to_forum($forum1, $user, ['mailnow' => 1]);
+            $digests[] = $post;
         }
 
         // Add 5 discussions to forum 2.
-        $record->forum = $forum2->id;
         for ($i = 0; $i < 5; $i++) {
-            $this->getDataGenerator()->get_plugin_generator('mod_forum')->create_discussion($record);
+            list($discussion, $post) = $this->helper_post_to_forum($forum2, $user, ['mailnow' => 1]);
+            $posts[] = $post;
         }
 
-        // Ensure that the creation times mean that the messages will be sent.
-        $this->helper_force_digest_mail_times();
-
         // Set the tested user's default maildigest setting.
         $DB->set_field('user', 'maildigest', 0, array('id' => $user->id));
 
@@ -456,7 +401,17 @@ class mod_forum_maildigest_testcase extends advanced_testcase {
         forum_set_user_maildigest($forum2, -1, $user);
 
         // One digest e-mail should be sent, and five individual notifications.
-        $this->helper_run_cron_check_count(1, 5, 1);
+        $expect = [
+            (object) [
+                'userid' => $user->id,
+                'messages' => 5,
+                'digests' => 1,
+            ],
+        ];
+        $this->queue_tasks_and_assert($expect);
+
+        $this->send_notifications_and_assert($user, $posts);
+        $this->send_digests_and_assert($user, $digests);
     }
 
     /**
@@ -475,28 +430,21 @@ class mod_forum_maildigest_testcase extends advanced_testcase {
         $course1 = $userhelper->courses->course1;
         $forum1 = $userhelper->forums->forum1;
         $forum2 = $userhelper->forums->forum2;
-
-        // Add a discussion to the forums.
-        $record = new stdClass();
-        $record->course = $course1->id;
-        $record->userid = $user->id;
-        $record->mailnow = 1;
+        $posts = [];
+        $digests = [];
 
         // Add 5 discussions to forum 1.
-        $record->forum = $forum1->id;
         for ($i = 0; $i < 5; $i++) {
-            $this->getDataGenerator()->get_plugin_generator('mod_forum')->create_discussion($record);
+            list($discussion, $post) = $this->helper_post_to_forum($forum1, $user, ['mailnow' => 1]);
+            $digests[] = $post;
         }
 
         // Add 5 discussions to forum 2.
-        $record->forum = $forum2->id;
         for ($i = 0; $i < 5; $i++) {
-            $this->getDataGenerator()->get_plugin_generator('mod_forum')->create_discussion($record);
+            list($discussion, $post) = $this->helper_post_to_forum($forum2, $user, ['mailnow' => 1]);
+            $posts[] = $post;
         }
 
-        // Ensure that the creation times mean that the messages will be sent.
-        $this->helper_force_digest_mail_times();
-
         // Set the tested user's default maildigest setting.
         $DB->set_field('user', 'maildigest', 1, array('id' => $user->id));
 
@@ -507,7 +455,17 @@ class mod_forum_maildigest_testcase extends advanced_testcase {
         forum_set_user_maildigest($forum2, 0, $user);
 
         // One digest e-mail should be sent, and five individual notifications.
-        $this->helper_run_cron_check_count(1, 5, 1);
+        $expect = [
+            (object) [
+                'userid' => $user->id,
+                'messages' => 5,
+                'digests' => 1,
+            ],
+        ];
+        $this->queue_tasks_and_assert($expect);
+
+        $this->send_notifications_and_assert($user, $posts);
+        $this->send_digests_and_assert($user, $digests);
     }
 
     /**
@@ -525,28 +483,21 @@ class mod_forum_maildigest_testcase extends advanced_testcase {
         $course1 = $userhelper->courses->course1;
         $forum1 = $userhelper->forums->forum1;
         $forum2 = $userhelper->forums->forum2;
-
-        // Add a discussion to the forums.
-        $record = new stdClass();
-        $record->course = $course1->id;
-        $record->userid = $user->id;
-        $record->mailnow = 1;
+        $fulldigests = [];
+        $shortdigests = [];
 
         // Add 5 discussions to forum 1.
-        $record->forum = $forum1->id;
         for ($i = 0; $i < 5; $i++) {
-            $this->getDataGenerator()->get_plugin_generator('mod_forum')->create_discussion($record);
+            list($discussion, $post) = $this->helper_post_to_forum($forum1, $user, ['mailnow' => 1]);
+            $fulldigests[] = $post;
         }
 
         // Add 5 discussions to forum 2.
-        $record->forum = $forum2->id;
         for ($i = 0; $i < 5; $i++) {
-            $this->getDataGenerator()->get_plugin_generator('mod_forum')->create_discussion($record);
+            list($discussion, $post) = $this->helper_post_to_forum($forum2, $user, ['mailnow' => 1]);
+            $shortdigests[] = $post;
         }
 
-        // Ensure that the creation times mean that the messages will be sent.
-        $this->helper_force_digest_mail_times();
-
         // Set the tested user's default maildigest setting.
         $DB->set_field('user', 'maildigest', 0, array('id' => $user->id));
 
@@ -557,7 +508,14 @@ class mod_forum_maildigest_testcase extends advanced_testcase {
         forum_set_user_maildigest($forum2, 2, $user);
 
         // One digest e-mail should be sent, and no individual notifications.
-        $this->helper_run_cron_check_count(1, 0, 1);
+        $expect = [
+            (object) [
+                'userid' => $user->id,
+                'digests' => 1,
+            ],
+        ];
+        $this->queue_tasks_and_assert($expect);
+
+        $this->send_digests_and_assert($user, $fulldigests, $shortdigests);
     }
-
 }
diff --git a/mod/forum/tests/qanda_test.php b/mod/forum/tests/qanda_test.php
new file mode 100644 (file)
index 0000000..417ea2a
--- /dev/null
@@ -0,0 +1,153 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle 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.
+//
+// Moodle 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 Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * The forum module mail generation tests for groups.
+ *
+ * @package    mod_forum
+ * @copyright  2013 Andrew Nicols
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->dirroot . '/mod/forum/lib.php');
+require_once(__DIR__ . '/cron_trait.php');
+require_once(__DIR__ . '/generator_trait.php');
+
+/**
+ * The forum module mail generation tests for groups.
+ *
+ * @copyright  2013 Andrew Nicols
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class mod_forum_qanda_testcase extends advanced_testcase {
+    // Make use of the cron tester trait.
+    use mod_forum_tests_cron_trait;
+
+    // Make use of the test generator trait.
+    use mod_forum_tests_generator_trait;
+
+    /**
+     * @var \phpunit_message_sink
+     */
+    protected $messagesink;
+
+    /**
+     * @var \phpunit_mailer_sink
+     */
+    protected $mailsink;
+
+    public function setUp() {
+        global $CFG;
+
+        // We must clear the subscription caches. This has to be done both before each test, and after in case of other
+        // tests using these functions.
+        \mod_forum\subscriptions::reset_forum_cache();
+        \mod_forum\subscriptions::reset_discussion_cache();
+
+        // Messaging is not compatible with transactions...
+        $this->preventResetByRollback();
+
+        // Catch all messages.
+        $this->messagesink = $this->redirectMessages();
+        $this->mailsink = $this->redirectEmails();
+
+        // Forcibly reduce the maxeditingtime to a second in the past to
+        // ensure that messages are sent out.
+        $CFG->maxeditingtime = -1;
+    }
+
+    public function tearDown() {
+        // We must clear the subscription caches. This has to be done both before each test, and after in case of other
+        // tests using these functions.
+        \mod_forum\subscriptions::reset_forum_cache();
+
+        $this->messagesink->clear();
+        $this->messagesink->close();
+        unset($this->messagesink);
+
+        $this->mailsink->clear();
+        $this->mailsink->close();
+        unset($this->mailsink);
+    }
+
+    /**
+     * Test that a user who has not posted in a q&a forum does not receive
+     * notificatinos.
+     */
+    public function test_user_has_not_posted() {
+        global $CFG, $DB;
+
+        $this->resetAfterTest(true);
+
+        // Create a course, with a forum.
+        $course = $this->getDataGenerator()->create_course();
+
+        $forum = $this->getDataGenerator()->create_module('forum', [
+            'course' => $course->id,
+            'forcesubscribe' => FORUM_INITIALSUBSCRIBE,
+            'groupmode' => SEPARATEGROUPS,
+            'type' => 'qanda',
+        ]);
+
+        // Create three students:
+        // - author, enrolled in group A; and
+        // - recipient, enrolled in group B; and
+        // - other, enrolled in the course, but no groups.
+        list($author, $recipient, $otheruser) = $this->helper_create_users($course, 3);
+
+        // Create one editing teacher, not in any group but with accessallgroups capability.
+        list($editingteacher) = $this->helper_create_users($course, 1, 'editingteacher');
+
+        // Post a discussion to the forum.
+        list($discussion, $post) = $this->helper_post_to_forum($forum, $editingteacher);
+        $reply = $this->helper_reply_to_post($post, $author);
+        $otherreply = $this->helper_reply_to_post($post, $recipient);
+        $DB->execute("UPDATE {forum_posts} SET modified = modified - 1");
+        $DB->execute("UPDATE {forum_posts} SET created = created - 1");
+        $DB->execute("UPDATE {forum_discussions} SET timemodified = timemodified - 1");
+
+        // Only the author, recipient, and teachers should receive.
+        $expect = [
+            'author' => (object) [
+                'userid' => $author->id,
+                'messages' => 3,
+            ],
+            'recipient' => (object) [
+                'userid' => $recipient->id,
+                'messages' => 3,
+            ],
+            'otheruser' => (object) [
+                'userid' => $otheruser->id,
+                'messages' => 1,
+            ],
+            'editingteacher' => (object) [
+                'userid' => $editingteacher->id,
+                'messages' => 3,
+            ],
+        ];
+        $this->queue_tasks_and_assert($expect);
+        $posts = [$post, $reply, $otherreply];
+
+        // No notifications should be queued.
+        $this->send_notifications_and_assert($author, $posts);
+        $this->send_notifications_and_assert($recipient, $posts);
+        $this->send_notifications_and_assert($otheruser, [$post]);
+        $this->send_notifications_and_assert($editingteacher, $posts);
+    }
+}