• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Freegle / iznik-server / #2550

17 Dec 2025 10:43AM UTC coverage: 89.531% (-0.003%) from 89.534%
#2550

push

php-coveralls

edwh
Fix flaky EngageTest by cleaning up engage table in setUp

The engage table tracks when users were sent engagement emails and
prevents re-sending within 7 days. This caused test isolation issues
when test users from previous runs still had entries.

Clean up engage table entries for test users at the start of each test.

26596 of 29706 relevant lines covered (89.53%)

31.63 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

98.06
/include/user/Engage.php
1
<?php
2
namespace Freegle\Iznik;
3

4

5

6
class Engage
7
{
8
    private $dbhr, $dbhm;
9

10
    const USER_INACTIVE = 365 * 24 * 60 * 60 / 2;
11
    const LOOKBACK = 31;
12

13
    const FILTER_INACTIVE = 'Inactive';
14

15
    const ENGAGEMENT_UT = 'UT';
16
    const ENGAGEMENT_NEW = 'New';
17
    const ENGAGEMENT_OCCASIONAL = 'Occasional';
18
    const ENGAGEMENT_FREQUENT = 'Frequent';
19
    const ENGAGEMENT_OBSESSED = 'Obsessed';
20
    const ENGAGEMENT_INACTIVE = 'Inactive';
21
    const ENGAGEMENT_ATRISK = 'AtRisk';
22
    const ENGAGEMENT_DORMANT = 'Dormant';
23

24
    function __construct(LoggedPDO $dbhr, LoggedPDO $dbhm)
25
    {
26
        $this->dbhr = $dbhr;
4✔
27
        $this->dbhm = $dbhm;
4✔
28

29
        $loader = new \Twig_Loader_Filesystem(IZNIK_BASE . '/mailtemplates/twig/engage');
4✔
30
        $this->twig = new \Twig_Environment($loader);
4✔
31
    }
32
    # Split out for UT to override
33

34
    public function sendOne($mailer, $message, $uid) {
35
        Mail::addHeaders($this->dbhr, $this->dbhm, $message, Mail::MISSING, $uid);
1✔
36
        $mailer->send($message);
1✔
37
    }
38

39
    public function findUsersByFilter($filter, $id = NULL, $limit = NULL) {
40
        $userids = [];
2✔
41
        $limq = $limit ? " LIMIT $limit " : "";
2✔
42

43
        switch ($filter) {
44
            case Engage::FILTER_INACTIVE: {
45
                # Find people who we'll stop sending mails to soon.  This time is related to sendOurMails in User.
46
                $activeon = date("Y-m-d", strtotime("@" . (time() - Engage::USER_INACTIVE + 7 * 24 * 60 * 60)));
2✔
47
                $uq = $id ? " AND users.id = $id " : "";
2✔
48
                $sql = "SELECT id FROM users WHERE DATE(lastaccess) = ? $uq $limq;";
2✔
49
                $users = $this->dbhr->preQuery($sql, [
2✔
50
                    $activeon
2✔
51
                ]);
2✔
52

53
                $userids = array_column($users, 'id');
2✔
54
                break;
2✔
55
            }
56
        }
57

58
        return $userids;
2✔
59
    }
60

61
    public function findUsersByEngagement($engagement, $id = NULL, $limit = NULL) {
62
        $uq = $id ? " users.id = $id AND " : "";
2✔
63
        $limq = $limit ? " LIMIT $limit " : "";
2✔
64

65
        $sql = "SELECT id FROM users WHERE $uq engagement = ? $limq;";
2✔
66
        $users = $this->dbhr->preQuery($sql, [
2✔
67
            $engagement
2✔
68
        ]);
2✔
69

70
        $userids = array_column($users, 'id');
2✔
71
        return $userids;
2✔
72
    }
73

74
    private function setEngagement($id, $engagement, &$count) {
75
        #error_log("Set engagement $id = $engagement");
76
        $count++;
1✔
77
        $this->dbhm->preExec("UPDATE users SET engagement = ? WHERE id = ?;", [
1✔
78
            $engagement,
1✔
79
            $id
1✔
80
        ], FALSE, FALSE);
1✔
81
    }
82

83
    private function postsSince($id, $time) {
84
        $messages = $this->dbhr->preQuery("SELECT COUNT(*) AS count FROM messages INNER JOIN messages_groups ON messages_groups.msgid = messages.id WHERE fromuser = ? AND messages.arrival >= ?;", [
1✔
85
            $id,
1✔
86
            date("Y-m-d", strtotime($time))
1✔
87
        ]);
1✔
88

89
        #error_log("Posts since {$messages[0]['count']}");
90
        return $messages[0]['count'];
1✔
91
    }
92

93
    private function lastPostOrReply($id) {
94
        $ret = NULL;
1✔
95

96
        $reply = $this->dbhr->preQuery("SELECT MAX(date) AS date FROM chat_messages WHERE userid = ?;", [
1✔
97
            $id
1✔
98
        ]);
1✔
99

100
        if (count($reply)) {
1✔
101
            $ret = $reply[0]['date'];
1✔
102
        }
103

104
        $messages = $this->dbhr->preQuery("SELECT MAX(messages.arrival) AS date FROM messages INNER JOIN messages_groups ON messages_groups.msgid = messages.id WHERE fromuser = ?;", [
1✔
105
            $id
1✔
106
        ]);
1✔
107

108
        if (count($messages)) {
1✔
109
            if (!$ret || (strtotime($messages[0]['date']) > strtotime($ret))) {
1✔
110
                $ret = $messages[0]['date'];
1✔
111
            }
112
        }
113

114
        return $ret;
1✔
115
    }
116

117
    private function engagementProgress($total, &$count) {
118
        $count++;
1✔
119

120
        if ($count % 1000 == 0) { error_log("...$count / $total"); }
1✔
121
    }
122

123
    public function updateEngagement($id = NULL) {
124
        $ret = 0;
1✔
125
        $uq = $id ? " users.id = $id AND " : "";
1✔
126

127
        $lookback = date("Y-m-d", strtotime("midnight " . Engage::LOOKBACK . " days ago"));
1✔
128

129
        # Set new users
130
        $users = $this->dbhr->preQuery("SELECT id FROM users WHERE $uq added >= ? AND engagement IS NULL ;", [
1✔
131
            $lookback
1✔
132
        ]);
1✔
133

134
        error_log("NULL => New " . count($users));
1✔
135

136
        $count = 0;
1✔
137
        foreach ($users as $user) {
1✔
138
            $this->engagementProgress(count($users), $count);
1✔
139
            $this->setEngagement($user['id'], Engage::ENGAGEMENT_NEW, $ret);
1✔
140
        }
141

142
        # NULL => Inactive
143
        $users = $this->dbhr->preQuery("SELECT id FROM users WHERE $uq engagement IS NULL ;");
1✔
144

145
        error_log("NULL => Inactive " . count($users));
1✔
146

147
        $count = 0;
1✔
148
        foreach ($users as $user) {
1✔
149
            $this->engagementProgress(count($users), $count);
×
150
            $this->setEngagement($user['id'], Engage::ENGAGEMENT_INACTIVE, $ret);
×
151
        }
152

153
        # New, Occasional => Inactive.
154
        $mysqltime = date("Y-m-d", strtotime("midnight 14 days ago"));
1✔
155
        $users = $this->dbhr->preQuery("SELECT id FROM users WHERE $uq (lastaccess IS NULL OR lastaccess < ?) AND (engagement IS NULL OR engagement = ? OR engagement = ?);", [
1✔
156
            $mysqltime,
1✔
157
            Engage::ENGAGEMENT_NEW,
1✔
158
            Engage::ENGAGEMENT_OCCASIONAL
1✔
159
        ]);
1✔
160

161
        error_log("New, Occasional => Inactive " . count($users));
1✔
162

163
        $count = 0;
1✔
164
        foreach ($users as $user) {
1✔
165
            $this->engagementProgress(count($users), $count);
1✔
166
            $this->setEngagement($user['id'], Engage::ENGAGEMENT_INACTIVE, $ret);
1✔
167
        }
168

169
        # Inactive => Dormant.
170
        $activeon = date("Y-m-d", strtotime("@" . (time() - Engage::USER_INACTIVE)));
1✔
171
        $users = $this->dbhr->preQuery("SELECT id FROM users WHERE $uq (lastaccess IS NULL OR lastaccess < ?) AND engagement != ?;", [
1✔
172
            $activeon,
1✔
173
            self::ENGAGEMENT_DORMANT
1✔
174
        ]);
1✔
175

176
        error_log("Inactive => Dormant " . count($users));
1✔
177

178
        $count = 0;
1✔
179
        foreach ($users as $user) {
1✔
180
            $this->engagementProgress(count($users), $count);
1✔
181
            $this->setEngagement($user['id'], Engage::ENGAGEMENT_DORMANT, $ret);
1✔
182
        }
183

184
        # New, Inactive, Dormant => Occasional.
185
        $activeon = date("Y-m-d", strtotime("midnight 14 days ago"));
1✔
186
        $users = $this->dbhr->preQuery("SELECT id FROM users WHERE lastaccess >= ? AND (engagement IS NULL OR engagement = ? OR engagement = ?  OR engagement = ?);", [
1✔
187
            $activeon,
1✔
188
            Engage::ENGAGEMENT_NEW,
1✔
189
            Engage::ENGAGEMENT_INACTIVE,
1✔
190
            Engage::ENGAGEMENT_DORMANT
1✔
191
        ]);
1✔
192

193
        error_log("New, Inactive, Dormant => Occasional " . count($users));
1✔
194
        $count = 0;
1✔
195
        foreach ($users as $user) {
1✔
196
            $this->engagementProgress(count($users), $count);
1✔
197
            $active = $this->lastPostOrReply($user['id']);
1✔
198
            #error_log("Last active $active");
199
            if ($active && strtotime($active) > time() - 14 * 24 * 60 *60) {
1✔
200
                $this->setEngagement($user['id'], Engage::ENGAGEMENT_OCCASIONAL, $ret);
1✔
201
            }
202
        }
203

204
        # Occasional => Frequent.
205
        $users = $this->dbhr->preQuery("SELECT id FROM users WHERE $uq engagement = ?;", [
1✔
206
            Engage::ENGAGEMENT_OCCASIONAL
1✔
207
        ]);
1✔
208

209
        error_log("Occasional => Frequent " . count($users));
1✔
210
        $count = 0;
1✔
211
        foreach ($users as $user) {
1✔
212
            $this->engagementProgress(count($users), $count);
1✔
213
            if ($this->postsSince($user['id'], "90 days ago") > 3) {
1✔
214
                $this->setEngagement($user['id'], Engage::ENGAGEMENT_FREQUENT, $ret);
1✔
215
            }
216
        }
217

218
        # Frequent => Obsessed.
219
        $users = $this->dbhr->preQuery("SELECT id FROM users WHERE $uq engagement = ?;", [
1✔
220
            Engage::ENGAGEMENT_FREQUENT
1✔
221
        ]);
1✔
222

223
        error_log("Frequent => Obsessed " . count($users));
1✔
224
        $count = 0;
1✔
225
        foreach ($users as $user) {
1✔
226
            $this->engagementProgress(count($users), $count);
1✔
227
            if ($this->postsSince($user['id'], "31 days ago") >= 4) {
1✔
228
                $this->setEngagement($user['id'], Engage::ENGAGEMENT_OBSESSED, $ret);
1✔
229
            }
230
        }
231

232
        # Obsessed => Frequent.
233
        $users = $this->dbhr->preQuery("SELECT id FROM users WHERE $uq engagement = ?;", [
1✔
234
            Engage::ENGAGEMENT_OBSESSED
1✔
235
        ]);
1✔
236

237
        error_log("Obsessed => Frequent " . count($users));
1✔
238
        $count = 0;
1✔
239
        foreach ($users as $user) {
1✔
240
            $this->engagementProgress(count($users), $count);
1✔
241
            if ($this->postsSince($user['id'], "90 days ago") <= 3) {
1✔
242
                $this->setEngagement($user['id'], Engage::ENGAGEMENT_FREQUENT, $ret);
1✔
243
            }
244
        }
245
    }
246

247
    public function sendUsers($engagement, $uids, $force = FALSE) {
248
        $count = 0;
2✔
249

250
        foreach ($uids as $uid) {
2✔
251
            $u = new User($this->dbhr, $this->dbhm, $uid);
2✔
252
            #error_log("Consider $uid");
253

254
            # Only send to users who have not turned off this kind of mail.
255
            if ($force || $u->getPrivate('relevantallowed')) {
2✔
256
                #error_log("...allowed by user");
257
                try {
258
                    // ...and who have a membership.
259
                    $membs = $u->getMemberships(FALSE, Group::GROUP_FREEGLE);
2✔
260

261
                    if (count($membs)) {
2✔
262
                        // ...and where that group allows engagement.
263
                        $enabled = FALSE;
2✔
264

265
                        foreach ($membs as $memb) {
2✔
266
                            $enabled |= array_key_exists('engagement', $memb['settings']) ? $memb['settings']['engagement'] : TRUE;
2✔
267
                        }
268

269
                        #error_log("...has membership");
270
                        if ($enabled) {
2✔
271
                            // ...and where we've not tried them in the last week.
272
                            $last = $this->dbhr->preQuery("SELECT MAX(timestamp) AS last FROM engage WHERE userid = ?;", [
1✔
273
                                $uid
1✔
274
                            ]);
1✔
275

276
                            if (!$last[0]['last'] || (time() - strtotime($last[0]['last']) > 7 * 24 * 60 * 60)) {
1✔
277
                                #error_log("...not tried recently");
278
                                list ($eid, $mail) = $this->chooseMail($uid, $engagement);
1✔
279
                                $subject = $mail['subject'];
1✔
280
                                $textbody = $mail['text'];
1✔
281
                                $template = $mail['template'] . '.html';
1✔
282

283
                                list ($transport, $mailer) = Mail::getMailer();
1✔
284
                                $m = \Swift_Message::newInstance()
1✔
285
                                    ->setSubject($subject)
1✔
286
                                    ->setFrom([NOREPLY_ADDR => SITE_NAME])
1✔
287
                                    ->setReplyTo(NOREPLY_ADDR)
1✔
288
                                    ->setTo($u->getEmailPreferred())
1✔
289
//                        ->setTo('log@ehibbert.org.uk')
1✔
290
                                    ->setBody($textbody);
1✔
291

292
                                Mail::addHeaders($this->dbhr, $this->dbhm, $m, Mail::MISSING, $u->getId());
1✔
293

294
                                $html = $this->twig->render($template, [
1✔
295
                                    'name' => $u->getName(),
1✔
296
                                    'email' => $u->getEmailPreferred(),
1✔
297
                                    'subject' => $subject,
1✔
298
                                    'unsubscribe' => $u->loginLink(USER_SITE, $u->getId(), "/unsubscribe", NULL),
1✔
299
                                    'engageid' => $eid
1✔
300
                                ]);
1✔
301

302
                                # Add HTML in base-64 as default quoted-printable encoding leads to problems on
303
                                # Outlook.
304
                                $htmlPart = \Swift_MimePart::newInstance();
1✔
305
                                $htmlPart->setCharset('utf-8');
1✔
306
                                $htmlPart->setEncoder(new \Swift_Mime_ContentEncoder_Base64ContentEncoder);
1✔
307
                                $htmlPart->setContentType('text/html');
1✔
308
                                $htmlPart->setBody($html);
1✔
309
                                $m->attach($htmlPart);
1✔
310

311
                                $this->sendOne($mailer, $m, $u->getId());
1✔
312
                                $count++;
2✔
313
                            }
314
                        }
315
                    }
316
                } catch (\Exception $e) { error_log("Failed " . $e->getMessage()); };
×
317
            }
318
        }
319

320
        return $count;
2✔
321
    }
322

323
    public function chooseMail($userid, $engagement) {
324
        # We want to choose a suitable mail to send to this user for their current engagement.  We use similar logic
325
        # to abtest, i.e. bandit testing.  We get the benefit of the best option, while still exploring others.
326
        # See http://stevehanov.ca/blog/index.php?id=132 for an example description.
327
        $variants = $this->dbhr->preQuery("SELECT * FROM engage_mails WHERE engagement = ? ORDER BY rate DESC, RAND();", [
1✔
328
            $engagement
1✔
329
        ]);
1✔
330

331
        $r = Utils::randomFloat();
1✔
332

333
        if ($r < 0.1) {
1✔
334
            # The 10% case we choose a random one of the other options.
335
            $s = rand(1, count($variants) - 1);
1✔
336
            $variant = $variants[$s];
1✔
337
        } else {
338
            # Most of the time we choose the currently best-performing option.
339
            $variant = count($variants) > 0 ? $variants[0] : NULL;
×
340
        }
341

342
        # Record that we've chosen this one.
343
        $this->dbhm->preExec("UPDATE engage_mails SET shown = shown + 1, rate = COALESCE(100 * action / shown, 0) WHERE id = ?;", [
1✔
344
            $variant['id']
1✔
345
        ]);
1✔
346

347
        # And record this specific attempt.
348
        $this->dbhm->preExec("INSERT INTO engage (userid, mailid, engagement, timestamp) VALUES (?, ?, ?, NOW());", [
1✔
349
            $userid,
1✔
350
            $variant['id'],
1✔
351
            $engagement
1✔
352
        ]);
1✔
353

354
        return [ $this->dbhm->lastInsertId(), $variant ];
1✔
355
    }
356

357
    public function recordSuccess($id) {
358
        $engages = $this->dbhr->preQuery("SELECT * FROM engage WHERE id = ?;", [
2✔
359
            $id
2✔
360
        ]);
2✔
361

362
        foreach ($engages as $engage) {
2✔
363
            $this->dbhm->preExec("UPDATE engage SET succeeded = NOW() WHERE id = ?;", [
1✔
364
                $id
1✔
365
            ]);
1✔
366

367
            # Update the stats for the corresponding email type.
368
            $this->dbhm->preExec("UPDATE engage_mails SET action = action + 1, rate = COALESCE(100 * action / shown, 0) WHERE id = ?;", [
1✔
369
                $engage['mailid']
1✔
370
            ]);
1✔
371
        }
372
    }
373

374
    public function process($id = NULL) {
375
        $count = 0;
2✔
376

377
        # Inactive users
378
        #
379
        # First the ones who will shortly become dormant.  We always want to send this, even if they have turned
380
        # off the normal engagement mails.
381
        $uids = $this->findUsersByFilter(Engage::FILTER_INACTIVE, $id);
2✔
382
        $count += $this->sendUsers(Engage::ENGAGEMENT_ATRISK, $uids, TRUE);
2✔
383

384
        # Then the other inactive ones.
385
        $uids = $this->findUsersByEngagement(Engage::ENGAGEMENT_INACTIVE, $id, 10000);
2✔
386
        $count += $this->sendUsers(Engage::FILTER_INACTIVE, $uids);
2✔
387

388
        return $count;
2✔
389
    }
390
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc