3 namespace Tests\Activity;
5 use BookStack\Activity\ActivityType;
6 use BookStack\Activity\Models\Comment;
7 use BookStack\Activity\Notifications\Messages\BaseActivityNotification;
8 use BookStack\Activity\Notifications\Messages\CommentCreationNotification;
9 use BookStack\Activity\Notifications\Messages\PageCreationNotification;
10 use BookStack\Activity\Notifications\Messages\PageUpdateNotification;
11 use BookStack\Activity\Tools\ActivityLogger;
12 use BookStack\Activity\Tools\UserEntityWatchOptions;
13 use BookStack\Activity\WatchLevels;
14 use BookStack\Entities\Models\Entity;
15 use BookStack\Settings\UserNotificationPreferences;
16 use Illuminate\Contracts\Notifications\Dispatcher;
17 use Illuminate\Support\Facades\Mail;
18 use Illuminate\Support\Facades\Notification;
21 class WatchTest extends TestCase
23 public function test_watch_action_exists_on_entity_unless_active()
25 $editor = $this->users->editor();
26 $this->actingAs($editor);
28 $entities = [$this->entities->book(), $this->entities->chapter(), $this->entities->page()];
29 /** @var Entity $entity */
30 foreach ($entities as $entity) {
31 $resp = $this->get($entity->getUrl());
32 $this->withHtml($resp)->assertElementContains('form[action$="/watching/update"] button.icon-list-item', 'Watch');
34 $watchOptions = new UserEntityWatchOptions($editor, $entity);
35 $watchOptions->updateLevelByValue(WatchLevels::COMMENTS);
37 $resp = $this->get($entity->getUrl());
38 $this->withHtml($resp)->assertElementNotExists('form[action$="/watching/update"] button.icon-list-item');
42 public function test_watch_action_only_shows_with_permission()
44 $viewer = $this->users->viewer();
45 $this->actingAs($viewer);
47 $entities = [$this->entities->book(), $this->entities->chapter(), $this->entities->page()];
48 /** @var Entity $entity */
49 foreach ($entities as $entity) {
50 $resp = $this->get($entity->getUrl());
51 $this->withHtml($resp)->assertElementNotExists('form[action$="/watching/update"] button.icon-list-item');
54 $this->permissions->grantUserRolePermissions($viewer, ['receive-notifications']);
56 /** @var Entity $entity */
57 foreach ($entities as $entity) {
58 $resp = $this->get($entity->getUrl());
59 $this->withHtml($resp)->assertElementExists('form[action$="/watching/update"] button.icon-list-item');
63 public function test_watch_update()
65 $editor = $this->users->editor();
66 $book = $this->entities->book();
68 $resp = $this->actingAs($editor)->put('/watching/update', [
69 'type' => $book->getMorphClass(),
74 $resp->assertRedirect($book->getUrl());
75 $this->assertSessionHas('success');
76 $this->assertDatabaseHas('watches', [
77 'watchable_id' => $book->id,
78 'watchable_type' => $book->getMorphClass(),
79 'user_id' => $editor->id,
80 'level' => WatchLevels::COMMENTS,
83 $resp = $this->put('/watching/update', [
84 'type' => $book->getMorphClass(),
88 $resp->assertRedirect($book->getUrl());
89 $this->assertDatabaseMissing('watches', [
90 'watchable_id' => $book->id,
91 'watchable_type' => $book->getMorphClass(),
92 'user_id' => $editor->id,
96 public function test_watch_update_fails_for_guest()
98 $this->setSettings(['app-public' => 'true']);
99 $guest = $this->users->guest();
100 $this->permissions->grantUserRolePermissions($guest, ['receive-notifications']);
101 $book = $this->entities->book();
103 $resp = $this->put('/watching/update', [
104 'type' => $book->getMorphClass(),
106 'level' => 'comments'
109 $this->assertPermissionError($resp);
110 $guest->unsetRelations();
113 public function test_watch_detail_display_reflects_state()
115 $editor = $this->users->editor();
116 $book = $this->entities->bookHasChaptersAndPages();
117 $chapter = $book->chapters()->first();
118 $page = $chapter->pages()->first();
120 (new UserEntityWatchOptions($editor, $book))->updateLevelByValue(WatchLevels::UPDATES);
122 $this->actingAs($editor)->get($book->getUrl())->assertSee('Watching new pages and updates');
123 $this->get($chapter->getUrl())->assertSee('Watching via parent book');
124 $this->get($page->getUrl())->assertSee('Watching via parent book');
126 (new UserEntityWatchOptions($editor, $chapter))->updateLevelByValue(WatchLevels::COMMENTS);
127 $this->get($chapter->getUrl())->assertSee('Watching new pages, updates & comments');
128 $this->get($page->getUrl())->assertSee('Watching via parent chapter');
130 (new UserEntityWatchOptions($editor, $page))->updateLevelByValue(WatchLevels::UPDATES);
131 $this->get($page->getUrl())->assertSee('Watching new pages and updates');
134 public function test_watch_detail_ignore_indicator_cascades()
136 $editor = $this->users->editor();
137 $book = $this->entities->bookHasChaptersAndPages();
138 (new UserEntityWatchOptions($editor, $book))->updateLevelByValue(WatchLevels::IGNORE);
140 $this->actingAs($editor)->get($book->getUrl())->assertSee('Ignoring notifications');
141 $this->get($book->chapters()->first()->getUrl())->assertSee('Ignoring via parent book');
142 $this->get($book->pages()->first()->getUrl())->assertSee('Ignoring via parent book');
145 public function test_watch_option_menu_shows_current_active_state()
147 $editor = $this->users->editor();
148 $book = $this->entities->book();
149 $options = new UserEntityWatchOptions($editor, $book);
151 $respHtml = $this->withHtml($this->actingAs($editor)->get($book->getUrl()));
152 $respHtml->assertElementNotExists('form[action$="/watching/update"] svg[data-icon="check-circle"]');
154 $options->updateLevelByValue(WatchLevels::COMMENTS);
155 $respHtml = $this->withHtml($this->actingAs($editor)->get($book->getUrl()));
156 $respHtml->assertElementExists('form[action$="/watching/update"] button[value="comments"] svg[data-icon="check-circle"]');
158 $options->updateLevelByValue(WatchLevels::IGNORE);
159 $respHtml = $this->withHtml($this->actingAs($editor)->get($book->getUrl()));
160 $respHtml->assertElementExists('form[action$="/watching/update"] button[value="ignore"] svg[data-icon="check-circle"]');
163 public function test_watch_option_menu_limits_options_for_pages()
165 $editor = $this->users->editor();
166 $book = $this->entities->bookHasChaptersAndPages();
167 (new UserEntityWatchOptions($editor, $book))->updateLevelByValue(WatchLevels::IGNORE);
169 $respHtml = $this->withHtml($this->actingAs($editor)->get($book->getUrl()));
170 $respHtml->assertElementExists('form[action$="/watching/update"] button[name="level"][value="new"]');
172 $respHtml = $this->withHtml($this->get($book->pages()->first()->getUrl()));
173 $respHtml->assertElementExists('form[action$="/watching/update"] button[name="level"][value="updates"]');
174 $respHtml->assertElementNotExists('form[action$="/watching/update"] button[name="level"][value="new"]');
177 public function test_notify_own_page_changes()
179 $editor = $this->users->editor();
180 $entities = $this->entities->createChainBelongingToUser($editor);
181 $prefs = new UserNotificationPreferences($editor);
182 $prefs->updateFromSettingsArray(['own-page-changes' => 'true']);
184 $notifications = Notification::fake();
187 $this->entities->updatePage($entities['page'], ['name' => 'My updated page', 'html' => 'Hello']);
188 $notifications->assertSentTo($editor, PageUpdateNotification::class);
191 public function test_notify_own_page_comments()
193 $editor = $this->users->editor();
194 $entities = $this->entities->createChainBelongingToUser($editor);
195 $prefs = new UserNotificationPreferences($editor);
196 $prefs->updateFromSettingsArray(['own-page-comments' => 'true']);
198 $notifications = Notification::fake();
200 $this->asAdmin()->post("/comment/{$entities['page']->id}", [
201 'html' => '<p>My new comment</p>'
203 $notifications->assertSentTo($editor, CommentCreationNotification::class);
206 public function test_notify_comment_replies()
208 $editor = $this->users->editor();
209 $entities = $this->entities->createChainBelongingToUser($editor);
210 $prefs = new UserNotificationPreferences($editor);
211 $prefs->updateFromSettingsArray(['comment-replies' => 'true']);
213 // Create some existing comments to pad IDs to help potentially error
214 // on mis-identification of parent via ids used.
215 Comment::factory()->count(5)
216 ->for($entities['page'], 'entity')
217 ->create(['created_by' => $this->users->admin()->id]);
219 $notifications = Notification::fake();
221 $this->actingAs($editor)->post("/comment/{$entities['page']->id}", [
222 'html' => '<p>My new comment</p>'
224 $comment = $entities['page']->comments()->orderBy('id', 'desc')->first();
226 $this->asAdmin()->post("/comment/{$entities['page']->id}", [
227 'html' => '<p>My new comment response</p>',
228 'parent_id' => $comment->local_id,
230 $notifications->assertSentTo($editor, CommentCreationNotification::class);
233 public function test_notify_watch_parent_book_ignore()
235 $editor = $this->users->editor();
236 $entities = $this->entities->createChainBelongingToUser($editor);
237 $watches = new UserEntityWatchOptions($editor, $entities['book']);
238 $prefs = new UserNotificationPreferences($editor);
239 $watches->updateLevelByValue(WatchLevels::IGNORE);
240 $prefs->updateFromSettingsArray(['own-page-changes' => 'true', 'own-page-comments' => true]);
242 $notifications = Notification::fake();
244 $this->asAdmin()->post("/comment/{$entities['page']->id}", [
245 'text' => 'My new comment response',
247 $this->entities->updatePage($entities['page'], ['name' => 'My updated page', 'html' => 'Hello']);
248 $notifications->assertNothingSent();
251 public function test_notify_watch_parent_book_comments()
253 $notifications = Notification::fake();
254 $editor = $this->users->editor();
255 $admin = $this->users->admin();
256 $entities = $this->entities->createChainBelongingToUser($editor);
257 $watches = new UserEntityWatchOptions($editor, $entities['book']);
258 $watches->updateLevelByValue(WatchLevels::COMMENTS);
261 $this->actingAs($admin)->post("/comment/{$entities['page']->id}", [
262 'html' => '<p>My new comment response</p>',
265 $notifications->assertSentTo($editor, function (CommentCreationNotification $notification) use ($editor, $admin, $entities) {
266 $mail = $notification->toMail($editor);
267 $mailContent = html_entity_decode(strip_tags($mail->render()), ENT_QUOTES);
268 return $mail->subject === 'New comment on page: ' . $entities['page']->getShortName()
269 && str_contains($mailContent, 'View Comment')
270 && str_contains($mailContent, 'Page Name: ' . $entities['page']->name)
271 && str_contains($mailContent, 'Page Path: ' . $entities['book']->getShortName(24) . ' > ' . $entities['chapter']->getShortName(24))
272 && str_contains($mailContent, 'Commenter: ' . $admin->name)
273 && str_contains($mailContent, 'Comment: My new comment response');
277 public function test_notify_watch_parent_book_updates()
279 $notifications = Notification::fake();
280 $editor = $this->users->editor();
281 $admin = $this->users->admin();
282 $entities = $this->entities->createChainBelongingToUser($editor);
283 $watches = new UserEntityWatchOptions($editor, $entities['book']);
284 $watches->updateLevelByValue(WatchLevels::UPDATES);
286 $this->actingAs($admin);
287 $this->entities->updatePage($entities['page'], ['name' => 'Updated page', 'html' => 'new page content']);
289 $notifications->assertSentTo($editor, function (PageUpdateNotification $notification) use ($editor, $admin, $entities) {
290 $mail = $notification->toMail($editor);
291 $mailContent = html_entity_decode(strip_tags($mail->render()), ENT_QUOTES);
292 return $mail->subject === 'Updated page: Updated page'
293 && str_contains($mailContent, 'View Page')
294 && str_contains($mailContent, 'Page Name: Updated page')
295 && str_contains($mailContent, 'Page Path: ' . $entities['book']->getShortName(24) . ' > ' . $entities['chapter']->getShortName(24))
296 && str_contains($mailContent, 'Updated By: ' . $admin->name)
297 && str_contains($mailContent, 'you won\'t be sent notifications for further edits to this page by the same editor');
301 $notifications = Notification::fake();
302 $this->entities->updatePage($entities['page'], ['name' => 'Updated page', 'html' => 'new page content']);
303 $notifications->assertNothingSentTo($editor);
306 public function test_notify_watch_parent_book_new()
308 $notifications = Notification::fake();
309 $editor = $this->users->editor();
310 $admin = $this->users->admin();
311 $entities = $this->entities->createChainBelongingToUser($editor);
312 $watches = new UserEntityWatchOptions($editor, $entities['book']);
313 $watches->updateLevelByValue(WatchLevels::NEW);
315 $this->actingAs($admin)->get($entities['chapter']->getUrl('/create-page'));
316 $page = $entities['chapter']->pages()->where('draft', '=', true)->first();
317 $this->post($page->getUrl(), ['name' => 'My new page', 'html' => 'My new page content']);
319 $notifications->assertSentTo($editor, function (PageCreationNotification $notification) use ($editor, $admin, $entities) {
320 $mail = $notification->toMail($editor);
321 $mailContent = html_entity_decode(strip_tags($mail->render()), ENT_QUOTES);
322 return $mail->subject === 'New page: My new page'
323 && str_contains($mailContent, 'View Page')
324 && str_contains($mailContent, 'Page Name: My new page')
325 && str_contains($mailContent, 'Page Path: ' . $entities['book']->getShortName(24) . ' > ' . $entities['chapter']->getShortName(24))
326 && str_contains($mailContent, 'Created By: ' . $admin->name);
330 public function test_notifications_sent_in_right_language()
332 $editor = $this->users->editor();
333 $admin = $this->users->admin();
334 setting()->putUser($editor, 'language', 'de');
335 $entities = $this->entities->createChainBelongingToUser($editor);
336 $watches = new UserEntityWatchOptions($editor, $entities['book']);
337 $watches->updateLevelByValue(WatchLevels::COMMENTS);
340 ActivityType::PAGE_CREATE => $entities['page'],
341 ActivityType::PAGE_UPDATE => $entities['page'],
342 ActivityType::COMMENT_CREATE => Comment::factory()->make([
343 'entity_id' => $entities['page']->id,
344 'entity_type' => $entities['page']->getMorphClass(),
348 $notifications = Notification::fake();
349 $logger = app()->make(ActivityLogger::class);
350 $this->actingAs($admin);
352 foreach ($activities as $activityType => $detail) {
353 $logger->add($activityType, $detail);
356 $sent = $notifications->sentNotifications()[get_class($editor)][$editor->id];
357 $this->assertCount(3, $sent);
359 foreach ($sent as $notificationInfo) {
360 $notification = $notificationInfo[0]['notification'];
361 $this->assertInstanceOf(BaseActivityNotification::class, $notification);
362 $mail = $notification->toMail($editor);
363 $mailContent = html_entity_decode(strip_tags($mail->render()), ENT_QUOTES);
364 $this->assertStringContainsString('Name der Seite:', $mailContent);
365 $this->assertStringContainsString('Diese Benachrichtigung wurde', $mailContent);
366 $this->assertStringContainsString('Sollte es beim Anklicken der Schaltfläche', $mailContent);
370 public function test_failed_notifications_dont_block_and_log_errors()
372 $logger = $this->withTestLogger();
373 $editor = $this->users->editor();
374 $admin = $this->users->admin();
375 $page = $this->entities->page();
377 $activityLogger = app()->make(ActivityLogger::class);
379 $watches = new UserEntityWatchOptions($editor, $book);
380 $watches->updateLevelByValue(WatchLevels::UPDATES);
382 $mockDispatcher = $this->mock(Dispatcher::class);
383 $mockDispatcher->shouldReceive('send')->once()
384 ->andThrow(\Exception::class, 'Failed to connect to mail server');
386 $this->actingAs($admin);
388 $activityLogger->add(ActivityType::PAGE_UPDATE, $page);
390 $this->assertTrue($logger->hasErrorThatContains("Failed to send email notification to user [id:{$editor->id}] with error: Failed to connect to mail server"));
393 public function test_notifications_not_sent_if_lacking_view_permission_for_related_item()
395 $notifications = Notification::fake();
396 $editor = $this->users->editor();
397 $page = $this->entities->page();
399 $watches = new UserEntityWatchOptions($editor, $page);
400 $watches->updateLevelByValue(WatchLevels::COMMENTS);
401 $this->permissions->disableEntityInheritedPermissions($page);
403 $this->asAdmin()->post("/comment/{$page->id}", [
404 'html' => '<p>My new comment response</p>',
407 $notifications->assertNothingSentTo($editor);
410 public function test_watches_deleted_on_user_delete()
412 $editor = $this->users->editor();
413 $page = $this->entities->page();
415 $watches = new UserEntityWatchOptions($editor, $page);
416 $watches->updateLevelByValue(WatchLevels::COMMENTS);
417 $this->assertDatabaseHas('watches', ['user_id' => $editor->id]);
419 $this->asAdmin()->delete($editor->getEditUrl());
421 $this->assertDatabaseMissing('watches', ['user_id' => $editor->id]);
424 public function test_watches_deleted_on_item_delete()
426 $editor = $this->users->editor();
427 $page = $this->entities->page();
429 $watches = new UserEntityWatchOptions($editor, $page);
430 $watches->updateLevelByValue(WatchLevels::COMMENTS);
431 $this->assertDatabaseHas('watches', ['watchable_type' => 'page', 'watchable_id' => $page->id]);
433 $this->entities->destroy($page);
435 $this->assertDatabaseMissing('watches', ['watchable_type' => 'page', 'watchable_id' => $page->id]);
438 public function test_page_path_in_notifications_limited_by_permissions()
440 $chapter = $this->entities->chapterHasPages();
441 $page = $chapter->pages()->first();
442 $book = $chapter->book;
443 $notification = new PageCreationNotification($page, $this->users->editor());
445 $viewer = $this->users->viewer();
446 $viewerRole = $viewer->roles()->first();
448 $content = html_entity_decode(strip_tags($notification->toMail($viewer)->render()), ENT_QUOTES);
449 $this->assertStringContainsString('Page Path: ' . $book->getShortName(24) . ' > ' . $chapter->getShortName(24), $content);
451 $this->permissions->setEntityPermissions($page, ['view'], [$viewerRole]);
452 $this->permissions->setEntityPermissions($chapter, [], [$viewerRole]);
454 $content = html_entity_decode(strip_tags($notification->toMail($viewer)->render()), ENT_QUOTES);
455 $this->assertStringContainsString('Page Path: ' . $book->getShortName(24), $content);
456 $this->assertStringNotContainsString(' > ' . $chapter->getShortName(24), $content);
458 $this->permissions->setEntityPermissions($book, [], [$viewerRole]);
460 $content = html_entity_decode(strip_tags($notification->toMail($viewer)->render()), ENT_QUOTES);
461 $this->assertStringNotContainsString('Page Path:', $content);
462 $this->assertStringNotContainsString($book->getShortName(24), $content);
463 $this->assertStringNotContainsString($chapter->getShortName(24), $content);