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

Freegle / Iznik / 10531

06 May 2026 11:51PM UTC coverage: 72.694%. First build
10531

Pull #370

circleci

edwh
fix(playwright): allow dns.google ERR_ADDRESS_UNREACHABLE in test fixtures

EmailValidator.vue makes a best-effort DNS-over-HTTPS lookup to
dns.google/resolve to validate email domains. When dns.google is
unreachable in isolated Docker CI environments, the browser logs
"Failed to load resource: net::ERR_ADDRESS_UNREACHABLE" to the console
even though the JS catch block already suppresses the error.

The test fixture's critical-error detector sees this and throws,
killing the test page and causing cascading failures in 9 Playwright
tests (reply flow, settings, post flow). The failure is intermittent
because dns.google is sometimes but not always reachable from the CI
Docker network.

Fix: add dns.google ERR_ADDRESS_UNREACHABLE to the allowed-error list,
consistent with the existing ERR_NAME_NOT_RESOLVED allowance for
external CDNs that are unreachable in Docker test environments.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Pull Request #370: feat(batch): migrate notification_chaseup.php to Laravel

13808 of 20788 branches covered (66.42%)

Branch coverage included in aggregate %.

212 of 282 new or added lines in 7 files covered. (75.18%)

99004 of 134399 relevant lines covered (73.66%)

22.74 hits per line

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

88.02
/iznik-batch/app/Services/NotificationChaseUpService.php
1
<?php
2

3
namespace App\Services;
4

5
use App\Mail\Notification\ChaseUpMail;
6
use App\Mail\Traits\AvatarResolver;
7
use App\Mail\Traits\FeatureFlags;
8
use App\Models\Notification;
9
use App\Models\User;
10
use Illuminate\Support\Collection;
11
use Illuminate\Support\Facades\DB;
12
use Illuminate\Support\Facades\Log;
13
use Illuminate\Support\Facades\Mail;
14

15
class NotificationChaseUpService
16
{
17
    use AvatarResolver;
18
    use FeatureFlags;
19

20
    public const EMAIL_TYPE = 'NotificationChaseUp';
21

22
    /**
23
     * How far back to look for notifications (oldest that qualify).
24
     */
25
    public const DEFAULT_SINCE_HOURS = 24;
26

27
    /**
28
     * Minimum age before sending — gives the user time to see the notification first.
29
     */
30
    public const DEFAULT_BEFORE_MINUTES = 5;
31

32
    /**
33
     * How long before a user is considered inactive (6 months).
34
     */
35
    private const INACTIVE_DAYS = 183;
36

37
    /**
38
     * Notification types that are never included in chaseup emails (mirrors V1).
39
     */
40
    public const EXCLUDED_TYPES = ['TryFeed', 'AboutMe', 'GiftAid', 'OpenPosts'];
41

42
    /**
43
     * Spam-user collection types — notifications from these users are excluded.
44
     */
45
    private const SPAM_COLLECTIONS = ['Spammer', 'PendingAdd'];
46

47
    /**
48
     * Send chaseup emails for unmailed, unseen notifications.
49
     *
50
     * Mirrors V1 Notifications::sendEmails() called from notification_chaseup.php.
51
     */
52
    public function sendEmails(
24✔
53
        ?int $userId = null,
54
        int $beforeMinutes = self::DEFAULT_BEFORE_MINUTES,
55
        int $sinceHours = self::DEFAULT_SINCE_HOURS,
56
        bool $dryRun = false
57
    ): int {
58
        if (! self::isEmailTypeEnabled(self::EMAIL_TYPE)) {
24✔
59
            Log::info('NotificationChaseUp emails disabled via FREEGLE_MAIL_ENABLED_TYPES');
1✔
60
            return 0;
1✔
61
        }
62

63
        $users = $this->getUsersWithPendingNotifications($userId, $beforeMinutes, $sinceHours);
23✔
64

65
        $total = 0;
23✔
66

67
        foreach ($users as $user) {
23✔
68
            $total += $this->processUser($user, $dryRun);
13✔
69
        }
70

71
        return $total;
23✔
72
    }
73

74
    /**
75
     * Find users with unmailed, unseen notifications in the given time window,
76
     * excluding spammers (from-user check, same as V1 SQL JOIN).
77
     */
78
    private function getUsersWithPendingNotifications(
23✔
79
        ?int $userId,
80
        int $beforeMinutes,
81
        int $sinceHours
82
    ): Collection {
83
        $before = now()->subMinutes($beforeMinutes);
23✔
84
        $since  = now()->subHours($sinceHours);
23✔
85

86
        $query = DB::table('users_notifications')
23✔
87
            ->leftJoin('spam_users', function ($join) {
23✔
88
                $join->on('spam_users.userid', '=', 'users_notifications.fromuser')
23✔
89
                    ->whereIn('spam_users.collection', self::SPAM_COLLECTIONS);
23✔
90
            })
23✔
91
            ->join('users', 'users.id', '=', 'users_notifications.touser')
23✔
92
            ->where('users_notifications.timestamp', '<=', $before)
23✔
93
            ->where('users_notifications.timestamp', '>=', $since)
23✔
94
            ->where('users_notifications.seen', 0)
23✔
95
            ->where('users_notifications.mailed', 0)
23✔
96
            ->whereNotIn('users_notifications.type', self::EXCLUDED_TYPES)
23✔
97
            ->whereNull('spam_users.userid')
23✔
98
            ->whereNull('users.deleted')
23✔
99
            ->select('users_notifications.touser')
23✔
100
            ->distinct();
23✔
101

102
        if ($userId !== null) {
23✔
103
            $query->where('users_notifications.touser', $userId);
1✔
104
        }
105

106
        $userIds = $query->pluck('touser');
23✔
107

108
        return User::whereIn('id', $userIds)->get();
23✔
109
    }
110

111
    /**
112
     * Process a single user: check eligibility, prepare notifications, send email.
113
     */
114
    private function processUser(User $user, bool $dryRun): int
13✔
115
    {
116
        if (! $this->isUserEligible($user)) {
13✔
117
            return 0;
4✔
118
        }
119

120
        if (! $this->wantsNotificationEmails($user)) {
9✔
121
            return 0;
1✔
122
        }
123

124
        $notifications = $this->getUserNotifications($user->id);
8✔
125
        if ($notifications->isEmpty()) {
8✔
NEW
126
            return 0;
×
127
        }
128

129
        $notifData = $this->prepareNotifications($notifications);
8✔
130
        if (empty($notifData)) {
8✔
NEW
131
            return 0;
×
132
        }
133

134
        $subject = $this->getNotifTitle($notifData);
8✔
135
        if (! $subject) {
8✔
NEW
136
            return 0;
×
137
        }
138

139
        $email = $user->email_preferred;
8✔
140
        if (! $email) {
8✔
NEW
141
            return 0;
×
142
        }
143

144
        if (! $dryRun) {
8✔
145
            // Mark as mailed before sending (like V1).
146
            $notifIds = array_column($notifData, 'id');
7✔
147
            DB::table('users_notifications')
7✔
148
                ->whereIn('id', $notifIds)
7✔
149
                ->update(['mailed' => 1]);
7✔
150

151
            Mail::to($email)->send(new ChaseUpMail($user, $notifData, $subject));
7✔
152
        }
153

154
        return 1;
8✔
155
    }
156

157
    /**
158
     * Check whether the user should receive emails at all.
159
     * Mirrors V1 User::sendOurMails() checks.
160
     */
161
    private function isUserEligible(User $user): bool
13✔
162
    {
163
        if ($user->deleted) {
13✔
NEW
164
            return false;
×
165
        }
166

167
        if ($user->getSimpleMail() === User::SIMPLE_MAIL_NONE) {
13✔
168
            return false;
1✔
169
        }
170

171
        // Must have been active in the last ~6 months.
172
        if (! $user->lastaccess || $user->lastaccess->diffInDays(now()) > self::INACTIVE_DAYS) {
12✔
173
            return false;
1✔
174
        }
175

176
        // Not on holiday.
177
        if ($user->onholidaytill && now()->lt($user->onholidaytill)) {
11✔
178
            return false;
1✔
179
        }
180

181
        // Not bouncing.
182
        if ($user->bouncing) {
10✔
183
            return false;
1✔
184
        }
185

186
        return true;
9✔
187
    }
188

189
    /**
190
     * Check the user's notification-mail preference (V1 settings.notificationmails, default true).
191
     */
192
    private function wantsNotificationEmails(User $user): bool
9✔
193
    {
194
        $settings = $user->settings ?? [];
9✔
195
        return (bool) ($settings['notificationmails'] ?? true);
9✔
196
    }
197

198
    /**
199
     * Fetch the latest unseen notifications for this user (mirrors V1 Notifications::get()).
200
     */
201
    private function getUserNotifications(int $userId): Collection
8✔
202
    {
203
        return Notification::where('touser', $userId)
8✔
204
            ->where('seen', 0)
8✔
205
            ->orderByDesc('id')
8✔
206
            ->limit(10)
8✔
207
            ->get();
8✔
208
    }
209

210
    /**
211
     * Enrich notification rows with from-user display names and newsfeed content.
212
     * Skips deleted items. Returns array suitable for template rendering.
213
     */
214
    private function prepareNotifications(Collection $notifications): array
8✔
215
    {
216
        // Bulk-load from-users.
217
        $fromUserIds = $notifications->pluck('fromuser')->filter()->unique()->values()->toArray();
8✔
218
        $fromUsers = User::whereIn('id', $fromUserIds)->select('id', 'fullname')->get()->keyBy('id');
8✔
219

220
        // Bulk-load newsfeed items and their reply-tos.
221
        $newsfeedIds = $notifications->pluck('newsfeedid')->filter()->unique()->values()->toArray();
8✔
222
        $newsfeeds = [];
8✔
223

224
        if (! empty($newsfeedIds)) {
8✔
225
            $rows = DB::table('newsfeed')->whereIn('id', $newsfeedIds)->get();
1✔
226

227
            foreach ($rows as $row) {
1✔
228
                $newsfeeds[$row->id] = $row;
1✔
229
            }
230

231
            $replytoIds = $rows->pluck('replyto')->filter()->unique()->values()->toArray();
1✔
232

233
            if (! empty($replytoIds)) {
1✔
NEW
234
                $replyRows = DB::table('newsfeed')->whereIn('id', $replytoIds)->get();
×
NEW
235
                foreach ($replyRows as $row) {
×
NEW
236
                    $newsfeeds[$row->id] = $row;
×
237
                }
238
            }
239
        }
240

241
        $result = [];
8✔
242

243
        foreach ($notifications as $notif) {
8✔
244
            if (in_array($notif->type, self::EXCLUDED_TYPES, true)) {
8✔
NEW
245
                continue;
×
246
            }
247

248
            $newsfeed = $notif->newsfeedid ? ($newsfeeds[$notif->newsfeedid] ?? null) : null;
8✔
249

250
            // Skip if the newsfeed item has been deleted.
251
            if ($newsfeed && $newsfeed->deleted) {
8✔
NEW
252
                continue;
×
253
            }
254

255
            // Skip if the thread being replied to has been deleted.
256
            $replyto = null;
8✔
257
            if ($newsfeed && $newsfeed->replyto) {
8✔
NEW
258
                $replyto = $newsfeeds[$newsfeed->replyto] ?? null;
×
NEW
259
                if ($replyto && $replyto->deleted) {
×
NEW
260
                    continue;
×
261
                }
262
            }
263

264
            $fromUser = isset($fromUsers[$notif->fromuser]) ? $fromUsers[$notif->fromuser] : null;
8✔
265

266
            $result[] = [
8✔
267
                'id'        => $notif->id,
8✔
268
                'type'      => $notif->type,
8✔
269
                'fromname'  => $fromUser ? $fromUser->fullname : 'Someone',
8✔
270
                'fromimage' => $this->resolveAvatarUrl($fromUser),
8✔
271
                'title'    => $notif->title,
8✔
272
                'text'     => $notif->text,
8✔
273
                'url'      => $notif->url,
8✔
274
                'timestamp' => \Carbon\Carbon::parse($notif->timestamp, 'UTC')
8✔
275
                                   ->setTimezone('Europe/London')
8✔
276
                                   ->format('D, jS F g:ia'),
8✔
277
                'seen'     => $notif->seen,
8✔
278
                'newsfeed' => $newsfeed ? [
8✔
279
                    'id'      => $newsfeed->id,
8✔
280
                    'type'    => $newsfeed->type ?? 'Message',
8✔
281
                    'message' => $this->snip($newsfeed->message),
8✔
282
                    'replyto' => $replyto ? [
1✔
283
                        'id'      => $replyto->id,
1✔
284
                        'message' => $this->snip($replyto->message),
1✔
285
                    ] : null,
286
                ] : null,
287
            ];
8✔
288
        }
289

290
        return $result;
8✔
291
    }
292

293
    /**
294
     * Generate the email subject line. Mirrors V1 Notifications::getNotifTitle().
295
     *
296
     * Priority: CommentOnCommented > CommentOnYourPost > LovedPost/LovedComment > Exhort.
297
     * If multiple notifications, appends "+ N more...".
298
     */
299
    public function getNotifTitle(array $notifs): string
17✔
300
    {
301
        $title = '';
17✔
302
        $count = 0;
17✔
303

304
        foreach ($notifs as $notif) {
17✔
305
            if ($notif['type'] === 'TryFeed') {
17✔
NEW
306
                continue;
×
307
            }
308

309
            $fromname = $notif['fromname'];
17✔
310
            $shortmsg = null;
17✔
311

312
            if (
313
                ! empty($notif['newsfeed']['message']) &&
17✔
314
                ($notif['newsfeed']['type'] ?? '') !== 'Noticeboard'
17✔
315
            ) {
316
                $msg      = $notif['newsfeed']['message'];
6✔
317
                $shortmsg = strlen($msg) > 30 ? (substr($msg, 0, 30) . '...') : $msg;
6✔
318
            }
319

320
            switch ($notif['type']) {
17✔
321
                case 'CommentOnCommented':
17✔
322
                    $title = "{$fromname} replied: {$shortmsg}";
1✔
323
                    $count++;
1✔
324
                    break;
1✔
325
                case 'CommentOnYourPost':
16✔
326
                    $title = "{$fromname} commented: {$shortmsg}";
7✔
327
                    $count++;
7✔
328
                    break;
7✔
329
                case 'LovedPost':
10✔
330
                    if (! $title) {
2✔
331
                        $title = "{$fromname} loved your post" . ($shortmsg ? " '{$shortmsg}'" : '');
1✔
332
                    }
333
                    $count++;
2✔
334
                    break;
2✔
335
                case 'LovedComment':
8✔
336
                    if (! $title) {
1✔
337
                        $title = "{$fromname} loved your comment" . ($shortmsg ? " '{$shortmsg}'" : '');
1✔
338
                    }
339
                    $count++;
1✔
340
                    break;
1✔
341
                case 'AboutMe':
7✔
NEW
342
                    if (! $title) {
×
NEW
343
                        $title = "Why not introduce yourself to other freeglers? You'll get a better response.";
×
344
                    }
NEW
345
                    $count++;
×
NEW
346
                    break;
×
347
                case 'Exhort':
7✔
348
                    if (! $title) {
1✔
349
                        $title = $notif['title'] ?? '';
1✔
350
                    }
351
                    $count++;
1✔
352
                    break;
1✔
353
                case 'MembershipPending':
6✔
354
                    if (! $title) {
2✔
355
                        $title = "Your application to {$notif['url']} requires approval";
2✔
356
                    }
357
                    $count++;
2✔
358
                    break;
2✔
359
                case 'MembershipApproved':
4✔
360
                    if (! $title) {
2✔
361
                        $title = "Your application to {$notif['url']} has been approved!";
2✔
362
                    }
363
                    $count++;
2✔
364
                    break;
2✔
365
                case 'MembershipRejected':
2✔
366
                    if (! $title) {
2✔
367
                        $title = "Sorry, your application to {$notif['url']} was rejected";
2✔
368
                    }
369
                    $count++;
2✔
370
                    break;
2✔
371
            }
372
        }
373

374
        if ($count > 1) {
17✔
375
            $title = $title . ' +' . ($count - 1) . ' more...';
1✔
376
        }
377

378
        return $title;
17✔
379
    }
380

381
    /**
382
     * Truncate a message to ~57 chars at a word boundary, matching V1 Notifications::snip().
383
     */
384
    private function snip(?string $msg): ?string
1✔
385
    {
386
        if (! $msg) {
1✔
NEW
387
            return $msg;
×
388
        }
389

390
        if (strlen($msg) > 57) {
1✔
NEW
391
            $msg = wordwrap($msg, 60);
×
NEW
392
            $p   = strpos($msg, "\n");
×
NEW
393
            $msg = ($p !== false) ? substr($msg, 0, $p) : $msg;
×
NEW
394
            $msg .= '...';
×
395
        }
396

397
        return $msg;
1✔
398
    }
399
}
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