{{ trans('preferences.shortcuts_overview_desc') }}
+{{ trans('preferences.notifications_desc') }}
+{{ trans('preferences.profile_overview_desc') }}
+{{ trans('preferences.notifications_watched_desc') }}
+ + @if($watches->isEmpty()) +{{ trans('common.no_items') }}
+ @else ++
{{ $line }}
@endforeach diff --git a/routes/web.php b/routes/web.php index 74ee74a2c..c7fc92fc7 100644 --- a/routes/web.php +++ b/routes/web.php @@ -194,6 +194,9 @@ Route::middleware('auth')->group(function () { Route::post('/favourites/add', [ActivityControllers\FavouriteController::class, 'add']); Route::post('/favourites/remove', [ActivityControllers\FavouriteController::class, 'remove']); + // Watching + Route::put('/watching/update', [ActivityControllers\WatchController::class, 'update']); + // Other Pages Route::get('/', [HomeController::class, 'index']); Route::get('/home', [HomeController::class, 'index']); @@ -228,9 +231,11 @@ Route::middleware('auth')->group(function () { Route::delete('/settings/users/{id}', [UserControllers\UserController::class, 'destroy']); // User Preferences - Route::redirect('/preferences', '/'); + Route::get('/preferences', [UserControllers\UserPreferencesController::class, 'index']); Route::get('/preferences/shortcuts', [UserControllers\UserPreferencesController::class, 'showShortcuts']); Route::put('/preferences/shortcuts', [UserControllers\UserPreferencesController::class, 'updateShortcuts']); + Route::get('/preferences/notifications', [UserControllers\UserPreferencesController::class, 'showNotifications']); + Route::put('/preferences/notifications', [UserControllers\UserPreferencesController::class, 'updateNotifications']); Route::patch('/preferences/change-view/{type}', [UserControllers\UserPreferencesController::class, 'changeView']); Route::patch('/preferences/change-sort/{type}', [UserControllers\UserPreferencesController::class, 'changeSort']); Route::patch('/preferences/change-expansion/{type}', [UserControllers\UserPreferencesController::class, 'changeExpansion']); diff --git a/tests/Activity/WatchTest.php b/tests/Activity/WatchTest.php new file mode 100644 index 000000000..fd86029d3 --- /dev/null +++ b/tests/Activity/WatchTest.php @@ -0,0 +1,332 @@ +users->editor(); + $this->actingAs($editor); + + $entities = [$this->entities->book(), $this->entities->chapter(), $this->entities->page()]; + /** @var Entity $entity */ + foreach ($entities as $entity) { + $resp = $this->get($entity->getUrl()); + $this->withHtml($resp)->assertElementContains('form[action$="/watching/update"] button.icon-list-item', 'Watch'); + + $watchOptions = new UserEntityWatchOptions($editor, $entity); + $watchOptions->updateLevelByValue(WatchLevels::COMMENTS); + + $resp = $this->get($entity->getUrl()); + $this->withHtml($resp)->assertElementNotExists('form[action$="/watching/update"] button.icon-list-item'); + } + } + + public function test_watch_action_only_shows_with_permission() + { + $viewer = $this->users->viewer(); + $this->actingAs($viewer); + + $entities = [$this->entities->book(), $this->entities->chapter(), $this->entities->page()]; + /** @var Entity $entity */ + foreach ($entities as $entity) { + $resp = $this->get($entity->getUrl()); + $this->withHtml($resp)->assertElementNotExists('form[action$="/watching/update"] button.icon-list-item'); + } + + $this->permissions->grantUserRolePermissions($viewer, ['receive-notifications']); + + /** @var Entity $entity */ + foreach ($entities as $entity) { + $resp = $this->get($entity->getUrl()); + $this->withHtml($resp)->assertElementExists('form[action$="/watching/update"] button.icon-list-item'); + } + } + + public function test_watch_update() + { + $editor = $this->users->editor(); + $book = $this->entities->book(); + + $this->actingAs($editor)->get($book->getUrl()); + $resp = $this->put('/watching/update', [ + 'type' => get_class($book), + 'id' => $book->id, + 'level' => 'comments' + ]); + + $resp->assertRedirect($book->getUrl()); + $this->assertSessionHas('success'); + $this->assertDatabaseHas('watches', [ + 'watchable_id' => $book->id, + 'watchable_type' => $book->getMorphClass(), + 'user_id' => $editor->id, + 'level' => WatchLevels::COMMENTS, + ]); + + $resp = $this->put('/watching/update', [ + 'type' => get_class($book), + 'id' => $book->id, + 'level' => 'default' + ]); + $resp->assertRedirect($book->getUrl()); + $this->assertDatabaseMissing('watches', [ + 'watchable_id' => $book->id, + 'watchable_type' => $book->getMorphClass(), + 'user_id' => $editor->id, + ]); + } + + public function test_watch_update_fails_for_guest() + { + $this->setSettings(['app-public' => 'true']); + $guest = $this->users->guest(); + $this->permissions->grantUserRolePermissions($guest, ['receive-notifications']); + $book = $this->entities->book(); + + $resp = $this->put('/watching/update', [ + 'type' => get_class($book), + 'id' => $book->id, + 'level' => 'comments' + ]); + + $this->assertPermissionError($resp); + $guest->unsetRelations(); + } + + public function test_watch_detail_display_reflects_state() + { + $editor = $this->users->editor(); + $book = $this->entities->bookHasChaptersAndPages(); + $chapter = $book->chapters()->first(); + $page = $chapter->pages()->first(); + + (new UserEntityWatchOptions($editor, $book))->updateLevelByValue(WatchLevels::UPDATES); + + $this->actingAs($editor)->get($book->getUrl())->assertSee('Watching new pages and updates'); + $this->get($chapter->getUrl())->assertSee('Watching via parent book'); + $this->get($page->getUrl())->assertSee('Watching via parent book'); + + (new UserEntityWatchOptions($editor, $chapter))->updateLevelByValue(WatchLevels::COMMENTS); + $this->get($chapter->getUrl())->assertSee('Watching new pages, updates & comments'); + $this->get($page->getUrl())->assertSee('Watching via parent chapter'); + + (new UserEntityWatchOptions($editor, $page))->updateLevelByValue(WatchLevels::UPDATES); + $this->get($page->getUrl())->assertSee('Watching new pages and updates'); + } + + public function test_watch_detail_ignore_indicator_cascades() + { + $editor = $this->users->editor(); + $book = $this->entities->bookHasChaptersAndPages(); + (new UserEntityWatchOptions($editor, $book))->updateLevelByValue(WatchLevels::IGNORE); + + $this->actingAs($editor)->get($book->getUrl())->assertSee('Ignoring notifications'); + $this->get($book->chapters()->first()->getUrl())->assertSee('Ignoring via parent book'); + $this->get($book->pages()->first()->getUrl())->assertSee('Ignoring via parent book'); + } + + public function test_watch_option_menu_shows_current_active_state() + { + $editor = $this->users->editor(); + $book = $this->entities->book(); + $options = new UserEntityWatchOptions($editor, $book); + + $respHtml = $this->withHtml($this->actingAs($editor)->get($book->getUrl())); + $respHtml->assertElementNotExists('form[action$="/watching/update"] svg[data-icon="check-circle"]'); + + $options->updateLevelByValue(WatchLevels::COMMENTS); + $respHtml = $this->withHtml($this->actingAs($editor)->get($book->getUrl())); + $respHtml->assertElementExists('form[action$="/watching/update"] button[value="comments"] svg[data-icon="check-circle"]'); + + $options->updateLevelByValue(WatchLevels::IGNORE); + $respHtml = $this->withHtml($this->actingAs($editor)->get($book->getUrl())); + $respHtml->assertElementExists('form[action$="/watching/update"] button[value="ignore"] svg[data-icon="check-circle"]'); + } + + public function test_watch_option_menu_limits_options_for_pages() + { + $editor = $this->users->editor(); + $book = $this->entities->bookHasChaptersAndPages(); + (new UserEntityWatchOptions($editor, $book))->updateLevelByValue(WatchLevels::IGNORE); + + $respHtml = $this->withHtml($this->actingAs($editor)->get($book->getUrl())); + $respHtml->assertElementExists('form[action$="/watching/update"] button[name="level"][value="new"]'); + + $respHtml = $this->withHtml($this->get($book->pages()->first()->getUrl())); + $respHtml->assertElementExists('form[action$="/watching/update"] button[name="level"][value="updates"]'); + $respHtml->assertElementNotExists('form[action$="/watching/update"] button[name="level"][value="new"]'); + } + + public function test_notify_own_page_changes() + { + $editor = $this->users->editor(); + $entities = $this->entities->createChainBelongingToUser($editor); + $prefs = new UserNotificationPreferences($editor); + $prefs->updateFromSettingsArray(['own-page-changes' => 'true']); + + $notifications = Notification::fake(); + + $this->asAdmin(); + $this->entities->updatePage($entities['page'], ['name' => 'My updated page', 'html' => 'Hello']); + $notifications->assertSentTo($editor, PageUpdateNotification::class); + } + + public function test_notify_own_page_comments() + { + $editor = $this->users->editor(); + $entities = $this->entities->createChainBelongingToUser($editor); + $prefs = new UserNotificationPreferences($editor); + $prefs->updateFromSettingsArray(['own-page-comments' => 'true']); + + $notifications = Notification::fake(); + + $this->asAdmin()->post("/comment/{$entities['page']->id}", [ + 'text' => 'My new comment' + ]); + $notifications->assertSentTo($editor, CommentCreationNotification::class); + } + + public function test_notify_comment_replies() + { + $editor = $this->users->editor(); + $entities = $this->entities->createChainBelongingToUser($editor); + $prefs = new UserNotificationPreferences($editor); + $prefs->updateFromSettingsArray(['comment-replies' => 'true']); + + $notifications = Notification::fake(); + + $this->actingAs($editor)->post("/comment/{$entities['page']->id}", [ + 'text' => 'My new comment' + ]); + $comment = $entities['page']->comments()->first(); + + $this->asAdmin()->post("/comment/{$entities['page']->id}", [ + 'text' => 'My new comment response', + 'parent_id' => $comment->id, + ]); + $notifications->assertSentTo($editor, CommentCreationNotification::class); + } + + public function test_notify_watch_parent_book_ignore() + { + $editor = $this->users->editor(); + $entities = $this->entities->createChainBelongingToUser($editor); + $watches = new UserEntityWatchOptions($editor, $entities['book']); + $prefs = new UserNotificationPreferences($editor); + $watches->updateLevelByValue(WatchLevels::IGNORE); + $prefs->updateFromSettingsArray(['own-page-changes' => 'true', 'own-page-comments' => true]); + + $notifications = Notification::fake(); + + $this->asAdmin()->post("/comment/{$entities['page']->id}", [ + 'text' => 'My new comment response', + ]); + $this->entities->updatePage($entities['page'], ['name' => 'My updated page', 'html' => 'Hello']); + $notifications->assertNothingSent(); + } + + public function test_notify_watch_parent_book_comments() + { + $notifications = Notification::fake(); + $editor = $this->users->editor(); + $admin = $this->users->admin(); + $entities = $this->entities->createChainBelongingToUser($editor); + $watches = new UserEntityWatchOptions($editor, $entities['book']); + $watches->updateLevelByValue(WatchLevels::COMMENTS); + + // Comment post + $this->actingAs($admin)->post("/comment/{$entities['page']->id}", [ + 'text' => 'My new comment response', + ]); + + $notifications->assertSentTo($editor, function (CommentCreationNotification $notification) use ($editor, $admin, $entities) { + $mail = $notification->toMail($editor); + $mailContent = html_entity_decode(strip_tags($mail->render())); + return $mail->subject === 'New comment on page: ' . $entities['page']->getShortName() + && str_contains($mailContent, 'View Comment') + && str_contains($mailContent, 'Page Name: ' . $entities['page']->name) + && str_contains($mailContent, 'Commenter: ' . $admin->name) + && str_contains($mailContent, 'Comment: My new comment response'); + }); + } + + public function test_notify_watch_parent_book_updates() + { + $notifications = Notification::fake(); + $editor = $this->users->editor(); + $admin = $this->users->admin(); + $entities = $this->entities->createChainBelongingToUser($editor); + $watches = new UserEntityWatchOptions($editor, $entities['book']); + $watches->updateLevelByValue(WatchLevels::UPDATES); + + $this->actingAs($admin); + $this->entities->updatePage($entities['page'], ['name' => 'Updated page', 'html' => 'new page content']); + + $notifications->assertSentTo($editor, function (PageUpdateNotification $notification) use ($editor, $admin) { + $mail = $notification->toMail($editor); + $mailContent = html_entity_decode(strip_tags($mail->render())); + return $mail->subject === 'Updated page: Updated page' + && str_contains($mailContent, 'View Page') + && str_contains($mailContent, 'Page Name: Updated page') + && str_contains($mailContent, 'Updated By: ' . $admin->name) + && str_contains($mailContent, 'you won\'t be sent notifications for further edits to this page by the same editor'); + }); + + // Test debounce + $notifications = Notification::fake(); + $this->entities->updatePage($entities['page'], ['name' => 'Updated page', 'html' => 'new page content']); + $notifications->assertNothingSentTo($editor); + } + + public function test_notify_watch_parent_book_new() + { + $notifications = Notification::fake(); + $editor = $this->users->editor(); + $admin = $this->users->admin(); + $entities = $this->entities->createChainBelongingToUser($editor); + $watches = new UserEntityWatchOptions($editor, $entities['book']); + $watches->updateLevelByValue(WatchLevels::NEW); + + $this->actingAs($admin)->get($entities['chapter']->getUrl('/create-page')); + $page = $entities['chapter']->pages()->where('draft', '=', true)->first(); + $this->post($page->getUrl(), ['name' => 'My new page', 'html' => 'My new page content']); + + $notifications->assertSentTo($editor, function (PageCreationNotification $notification) use ($editor, $admin) { + $mail = $notification->toMail($editor); + $mailContent = html_entity_decode(strip_tags($mail->render())); + return $mail->subject === 'New page: My new page' + && str_contains($mailContent, 'View Page') + && str_contains($mailContent, 'Page Name: My new page') + && str_contains($mailContent, 'Created By: ' . $admin->name); + }); + } + + public function test_notifications_not_sent_if_lacking_view_permission_for_related_item() + { + $notifications = Notification::fake(); + $editor = $this->users->editor(); + $page = $this->entities->page(); + + $watches = new UserEntityWatchOptions($editor, $page); + $watches->updateLevelByValue(WatchLevels::COMMENTS); + $this->permissions->disableEntityInheritedPermissions($page); + + $this->asAdmin()->post("/comment/{$page->id}", [ + 'text' => 'My new comment response', + ])->assertOk(); + + $notifications->assertNothingSentTo($editor); + } +} diff --git a/tests/Helpers/UserRoleProvider.php b/tests/Helpers/UserRoleProvider.php index b86e90394..3b2da369d 100644 --- a/tests/Helpers/UserRoleProvider.php +++ b/tests/Helpers/UserRoleProvider.php @@ -50,6 +50,14 @@ class UserRoleProvider return $user; } + /** + * Get the system "guest" user. + */ + public function guest(): User + { + return User::getDefault(); + } + /** * Create a new fresh user without any relations. */ diff --git a/tests/TestCase.php b/tests/TestCase.php index 322ab0370..0ab0792bd 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -5,6 +5,7 @@ namespace Tests; use BookStack\Entities\Models\Entity; use BookStack\Settings\SettingService; use BookStack\Uploads\HttpFetcher; +use BookStack\Users\Models\User; use GuzzleHttp\Client; use GuzzleHttp\Handler\MockHandler; use GuzzleHttp\HandlerStack; @@ -46,6 +47,7 @@ abstract class TestCase extends BaseTestCase $this->permissions = new PermissionsProvider($this->users); $this->files = new FileProvider(); + User::clearDefault(); parent::setUp(); // We can uncomment the below to run tests with failings upon deprecations. diff --git a/tests/User/UserPreferencesTest.php b/tests/User/UserPreferencesTest.php index e47a259a5..9d72f4e14 100644 --- a/tests/User/UserPreferencesTest.php +++ b/tests/User/UserPreferencesTest.php @@ -2,10 +2,30 @@ namespace Tests\User; +use BookStack\Activity\Tools\UserEntityWatchOptions; +use BookStack\Activity\WatchLevels; use Tests\TestCase; class UserPreferencesTest extends TestCase { + public function test_index_view() + { + $resp = $this->asEditor()->get('/preferences'); + $resp->assertOk(); + $resp->assertSee('Interface Keyboard Shortcuts'); + $resp->assertSee('Edit Profile'); + } + + public function test_index_view_accessible_but_without_profile_and_notifications_for_guest_user() + { + $this->setSettings(['app-public' => 'true']); + $this->permissions->grantUserRolePermissions($this->users->guest(), ['receive-notifications']); + $resp = $this->get('/preferences'); + $resp->assertOk(); + $resp->assertSee('Interface Keyboard Shortcuts'); + $resp->assertDontSee('Edit Profile'); + $resp->assertDontSee('Notification'); + } public function test_interface_shortcuts_updating() { $this->asEditor(); @@ -45,6 +65,80 @@ class UserPreferencesTest extends TestCase $this->withHtml($this->get('/'))->assertElementExists('body[component="shortcuts"]'); } + public function test_notification_routes_requires_notification_permission() + { + $viewer = $this->users->viewer(); + $resp = $this->actingAs($viewer)->get('/preferences/notifications'); + $this->assertPermissionError($resp); + + $resp = $this->put('/preferences/notifications'); + $this->assertPermissionError($resp); + + $this->permissions->grantUserRolePermissions($viewer, ['receive-notifications']); + $resp = $this->get('/preferences/notifications'); + $resp->assertOk(); + $resp->assertSee('Notification Preferences'); + } + + public function test_notification_preferences_updating() + { + $editor = $this->users->editor(); + + // View preferences with defaults + $resp = $this->actingAs($editor)->get('/preferences/notifications'); + $resp->assertSee('Notification Preferences'); + + $html = $this->withHtml($resp); + $html->assertFieldHasValue('preferences[comment-replies]', 'false'); + + // Update preferences + $resp = $this->put('/preferences/notifications', [ + 'preferences' => ['comment-replies' => 'true'], + ]); + + $resp->assertRedirect('/preferences/notifications'); + $resp->assertSessionHas('success', 'Notification preferences have been updated!'); + + // View updates to preferences page + $resp = $this->get('/preferences/notifications'); + $html = $this->withHtml($resp); + $html->assertFieldHasValue('preferences[comment-replies]', 'true'); + } + + public function test_notification_preferences_show_watches() + { + $editor = $this->users->editor(); + $book = $this->entities->book(); + + $options = new UserEntityWatchOptions($editor, $book); + $options->updateLevelByValue(WatchLevels::COMMENTS); + + $resp = $this->actingAs($editor)->get('/preferences/notifications'); + $resp->assertSee($book->name); + $resp->assertSee('All Page Updates & Comments'); + + $options->updateLevelByValue(WatchLevels::DEFAULT); + + $resp = $this->actingAs($editor)->get('/preferences/notifications'); + $resp->assertDontSee($book->name); + $resp->assertDontSee('All Page Updates & Comments'); + } + + public function test_notification_preferences_not_accessible_to_guest() + { + $this->setSettings(['app-public' => 'true']); + $guest = $this->users->guest(); + $this->permissions->grantUserRolePermissions($guest, ['receive-notifications']); + + $resp = $this->get('/preferences/notifications'); + $this->assertPermissionError($resp); + + $resp = $this->put('/preferences/notifications', [ + 'preferences' => ['comment-replies' => 'true'], + ]); + $this->assertPermissionError($resp); + } + public function test_update_sort_preference() { $editor = $this->users->editor();