From 4bed0cdae13f2123bd8562edb270c5cb48872279 Mon Sep 17 00:00:00 2001 From: William Allen Date: Fri, 9 Oct 2026 09:17:02 -0400 Subject: [PATCH] Revamp project notifications page The project notifications page is the last page built with XSLT, and has numerous bugs. This page should eventually be rewritten to query and mutate data via GraphQL, but that requires some heavy lifting which isn't likely to be done for a few months. This PR rewrites the page with Vue and DaisyUI, using data generated server-side as an interim solution. I also wrote some basic tests. --- .../ProjectNotificationsController.php | 116 +++++++++ .../SubscribeProjectController.php | 179 -------------- app/cdash/public/subscribeProject.xsl | 218 ----------------- app/cdash/tests/CMakeLists.txt | 4 + phpstan-baseline.neon | 36 --- resources/js/vue/app.js | 1 + .../components/ProjectNotificationsPage.vue | 221 ++++++++++++++++++ resources/views/components/header.blade.php | 2 +- routes/web.php | 15 +- .../Pages/ProjectNotificationsPageTest.php | 130 +++++++++++ tests/Feature/ProjectNotificationsTest.php | 205 ++++++++++++++++ 11 files changed, 689 insertions(+), 438 deletions(-) create mode 100644 app/Http/Controllers/ProjectNotificationsController.php delete mode 100644 app/Http/Controllers/SubscribeProjectController.php delete mode 100644 app/cdash/public/subscribeProject.xsl create mode 100644 resources/js/vue/components/ProjectNotificationsPage.vue mode change 100755 => 100644 resources/views/components/header.blade.php create mode 100644 tests/Browser/Pages/ProjectNotificationsPageTest.php create mode 100644 tests/Feature/ProjectNotificationsTest.php diff --git a/app/Http/Controllers/ProjectNotificationsController.php b/app/Http/Controllers/ProjectNotificationsController.php new file mode 100644 index 0000000000..5d96d50ae6 --- /dev/null +++ b/app/Http/Controllers/ProjectNotificationsController.php @@ -0,0 +1,116 @@ + BitmaskNotificationPreferences::EMAIL_UPDATE, + 'configure' => BitmaskNotificationPreferences::EMAIL_CONFIGURE, + 'warning' => BitmaskNotificationPreferences::EMAIL_WARNING, + 'error' => BitmaskNotificationPreferences::EMAIL_ERROR, + 'test' => BitmaskNotificationPreferences::EMAIL_TEST, + 'dynamicanalysis' => BitmaskNotificationPreferences::EMAIL_DYNAMIC_ANALYSIS, + ]; + + public function show(int $project_id): View + { + $this->setProjectById($project_id); + + /** @var User $user */ + $user = auth()->user(); + + $preferences = self::getPreferences($user, $project_id); + $eloquentProject = Project::findOrFail($project_id); + + $emailCategories = []; + foreach (self::EMAIL_CATEGORIES as $category => $bit) { + if (($preferences->emailcategory & $bit) !== 0) { + $emailCategories[] = $category; + } + } + + return $this->vue('project-notifications-page', 'Notifications', [ + 'project-id' => $eloquentProject->id, + 'project-emails-enabled' => $eloquentProject->emailbrokensubmission, + 'can-edit-project' => $user->can('update', $eloquentProject), + 'email-type' => $preferences->emailtype, + 'email-success' => (bool) $preferences->emailsuccess, + 'email-missing-sites' => (bool) $preferences->emailmissingsites, + 'email-categories' => $emailCategories, + 'message' => session('message', ''), + ]); + } + + /** + * @throws ValidationException + */ + public function update(Request $request, int $project_id): RedirectResponse + { + $this->setProjectById($project_id); + + /** @var User $user */ + $user = $request->user(); + + // Aborts if the user is not a member of the project. + self::getPreferences($user, $project_id); + + $validated = $request->validate([ + 'emailtype' => ['required', 'integer', Rule::in([0, 1, 2, 3])], + 'emailsuccess' => ['boolean'], + 'emailmissingsites' => ['boolean'], + 'emailcategories' => ['array'], + 'emailcategories.*' => ['string', Rule::in(array_keys(self::EMAIL_CATEGORIES))], + ]); + + $emailCategory = 0; + foreach ($validated['emailcategories'] ?? [] as $category) { + $emailCategory |= self::EMAIL_CATEGORIES[$category]; + } + + $user->projects()->updateExistingPivot($project_id, [ + 'emailtype' => (int) $validated['emailtype'], + 'emailcategory' => $emailCategory, + 'emailsuccess' => $request->boolean('emailsuccess'), + 'emailmissingsites' => $request->boolean('emailmissingsites'), + ]); + + return redirect("/projects/{$project_id}/notifications") + ->with('message', 'Your notification preferences have been updated.'); + } + + /** + * Notification preferences are stored on the user's project membership, so only members have any. + * + * @return Pivot&object{emailtype: int, emailcategory: int, emailsuccess: int, emailmissingsites: int} + */ + private static function getPreferences(User $user, int $project_id): Pivot + { + /** @var (Pivot&object{emailtype: int, emailcategory: int, emailsuccess: int, emailmissingsites: int})|null $preferences */ + $preferences = $user->projects() + ->withPivot(['emailtype', 'emailcategory', 'emailsuccess', 'emailmissingsites']) + ->find($project_id) + ?->pivot; + + if ($preferences === null) { + abort(403, 'You must be a member of this project to manage its notifications.'); + } + + return $preferences; + } +} diff --git a/app/Http/Controllers/SubscribeProjectController.php b/app/Http/Controllers/SubscribeProjectController.php deleted file mode 100644 index fb8bd5dc51..0000000000 --- a/app/Http/Controllers/SubscribeProjectController.php +++ /dev/null @@ -1,179 +0,0 @@ -setProjectById((int) $_GET['projectid']); - - $db = Database::getInstance(); - - $user2project = $db->executePreparedSingleRow(' - SELECT role, emailtype, emailcategory, emailmissingsites, emailsuccess - FROM user2project - WHERE userid=? AND projectid=? - ', [$user->id, $this->project->Id]); - if (!empty($user2project)) { - $xml .= add_XML_value('role', $user2project['role']); - $xml .= add_XML_value('emailtype', $user2project['emailtype']); - $xml .= add_XML_value('emailmissingsites', $user2project['emailmissingsites']); - $xml .= add_XML_value('emailsuccess', $user2project['emailsuccess']); - $emailcategory = $user2project['emailcategory']; - $xml .= add_XML_value('emailcategory_update', self::check_email_category('update', $emailcategory)); - $xml .= add_XML_value('emailcategory_configure', self::check_email_category('configure', $emailcategory)); - $xml .= add_XML_value('emailcategory_warning', self::check_email_category('warning', $emailcategory)); - $xml .= add_XML_value('emailcategory_error', self::check_email_category('error', $emailcategory)); - $xml .= add_XML_value('emailcategory_test', self::check_email_category('test', $emailcategory)); - $xml .= add_XML_value('emailcategory_dynamicanalysis', self::check_email_category('dynamicanalysis', $emailcategory)); - } else { - // we set the default categories - $xml .= add_XML_value('emailcategory_update', 1); - $xml .= add_XML_value('emailcategory_configure', 1); - $xml .= add_XML_value('emailcategory_warning', 1); - $xml .= add_XML_value('emailcategory_error', 1); - $xml .= add_XML_value('emailcategory_test', 1); - $xml .= add_XML_value('emailcategory_dynamicanalysis', 1); - } - - // If we ask to subscribe - @$UpdateSubscription = $_POST['updatesubscription']; - @$EmailType = $_POST['emailtype']; - if (!isset($_POST['emailmissingsites'])) { - $EmailMissingSites = 0; - } else { - $EmailMissingSites = $_POST['emailmissingsites']; - } - if (!isset($_POST['emailsuccess'])) { - $EmailSuccess = 0; - } else { - $EmailSuccess = $_POST['emailsuccess']; - } - - if ($UpdateSubscription) { - $emailcategory_update = (int) ($_POST['emailcategory_update'] ?? 0); - $emailcategory_configure = (int) ($_POST['emailcategory_configure'] ?? 0); - $emailcategory_warning = (int) ($_POST['emailcategory_warning'] ?? 0); - $emailcategory_error = (int) ($_POST['emailcategory_error'] ?? 0); - $emailcategory_test = (int) ($_POST['emailcategory_test'] ?? 0); - $emailcategory_dynamicanalysis = (int) ($_POST['emailcategory_dynamicanalysis'] ?? 0); - - $EmailCategory = $emailcategory_update + $emailcategory_configure + $emailcategory_warning + $emailcategory_error + $emailcategory_test + $emailcategory_dynamicanalysis; - if (!empty($user2project)) { - $db->executePrepared(' - UPDATE user2project - SET - emailtype=?, - emailcategory=?, - emailmissingsites=?, - emailsuccess=? - WHERE - userid=? - AND projectid=? - ', [ - $EmailType, - $EmailCategory, - $EmailMissingSites, - $EmailSuccess, - $user->id, - $this->project->Id, - ]); - } - - // Redirect - return redirect('/user'); - } - - $xml .= ''; - $xml .= add_XML_value('id', $this->project->Id); - $xml .= add_XML_value('name', $this->project->Name); - $xml .= add_XML_value('emailbrokensubmission', $this->project->EmailBrokenSubmission); - - $xml .= ''; - - foreach (Project::whereRelation('users', 'id', $user->id)->orderBy('name')->get() as $project) { - $xml .= ''; - $xml .= add_XML_value('id', $project->id); - $xml .= add_XML_value('name', $project->name); - if ((int) $project->id === $this->project->Id) { - $xml .= add_XML_value('selected', '1'); - } - $xml .= ''; - } - - $xml .= ''; - - return $this->view('cdash', 'Subscription Settings') - ->with('xsl', true) - ->with('xsl_content', generate_XSLT($xml, base_path() . '/app/cdash/public/subscribeProject', true)); - } - - /** - * Check the email category - */ - private static function check_email_category(string $name, int $emailcategory): bool - { - if ($emailcategory >= 64) { - if ($name === 'dynamicanalysis') { - return true; - } - $emailcategory -= 64; - } - - if ($emailcategory >= 32) { - if ($name === 'test') { - return true; - } - $emailcategory -= 32; - } - - if ($emailcategory >= 16) { - if ($name === 'error') { - return true; - } - $emailcategory -= 16; - } - - if ($emailcategory >= 8) { - if ($name === 'warning') { - return true; - } - $emailcategory -= 8; - } - - if ($emailcategory >= 4) { - if ($name === 'configure') { - return true; - } - $emailcategory -= 4; - } - - if ($emailcategory >= 2) { - if ($name === 'update') { - return true; - } - } - return false; - } -} diff --git a/app/cdash/public/subscribeProject.xsl b/app/cdash/public/subscribeProject.xsl deleted file mode 100644 index bf5190c33f..0000000000 --- a/app/cdash/public/subscribeProject.xsl +++ /dev/null @@ -1,218 +0,0 @@ - - - - - - - - - - - - - - - - - - - - -
Project:
- -
- subscribeProject.php?projectid= - -
-
- - - -
-
-
- -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
*This project has not been configured to send emails. - - - project//settings#EmailChange the project settings. - - Contact the project administrator. - -
Email me:
- - - - never (this is not recommended) -
- - - - when my checkins are causing problems in any sections of the dashboard -
- - - - when any checkins are causing problems in the Nightly section of the dashboard -
- - when any checkins are causing problems in any sections of the dashboard -
- - - - when my checkins are fixing build errors, warnings or tests -
- - - - when expected sites are not submitting -
-
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
*This project has not been configured to send emails. - - - project//settings#EmailChange the project settings. - - Contact the project administrator. - -
- - - - Update -
- - - - Configure -
- - - - Warning -
- - - - Error -
- - - - Test -
- - - - Dynamic Analysis -
-
-
- -
-
- - - - -
-
-
- -
-
-
-
diff --git a/app/cdash/tests/CMakeLists.txt b/app/cdash/tests/CMakeLists.txt index 480e06a336..54d3c9fcc5 100644 --- a/app/cdash/tests/CMakeLists.txt +++ b/app/cdash/tests/CMakeLists.txt @@ -195,6 +195,8 @@ add_feature_test_in_transaction(/Feature/ProjectInvitationAcceptanceTest) add_feature_test_in_transaction(/Feature/UpdateProjectLogoTest) +add_feature_test_in_transaction(/Feature/ProjectNotificationsTest) + add_feature_test_in_transaction(/Feature/GraphQL/FilterTest) add_feature_test_in_transaction(/Feature/GraphQL/Queries/QueryTypeTest) @@ -408,6 +410,8 @@ add_browser_test(/Browser/Pages/ProjectSitesPageTest) add_browser_test(/Browser/Pages/ProjectMembersPageTest) +add_browser_test(/Browser/Pages/ProjectNotificationsPageTest) + add_browser_test(/Browser/Pages/ProjectBuildsPageTest) add_browser_test(/Browser/Pages/CreateProjectPageTest) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 45f7a560d8..2682ccde58 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1404,42 +1404,6 @@ parameters: count: 3 path: app/Http/Controllers/SubmissionController.php - - - rawMessage: ''' - Call to deprecated method executePrepared() of class CDash\Database: - 04/22/2023 Use Laravel query builder or Eloquent instead - ''' - identifier: method.deprecated - count: 1 - path: app/Http/Controllers/SubscribeProjectController.php - - - - rawMessage: ''' - Call to deprecated method executePreparedSingleRow() of class CDash\Database: - 04/22/2023 Use Laravel query builder or Eloquent instead - ''' - identifier: method.deprecated - count: 1 - path: app/Http/Controllers/SubscribeProjectController.php - - - - rawMessage: Cannot cast mixed to int. - identifier: cast.int - count: 6 - path: app/Http/Controllers/SubscribeProjectController.php - - - - rawMessage: 'Construct empty() is not allowed. Use more strict comparison.' - identifier: empty.notAllowed - count: 2 - path: app/Http/Controllers/SubscribeProjectController.php - - - - rawMessage: 'Only booleans are allowed in an if condition, mixed given.' - identifier: if.condNotBoolean - count: 1 - path: app/Http/Controllers/SubscribeProjectController.php - - rawMessage: Cannot cast mixed to int. identifier: cast.int diff --git a/resources/js/vue/app.js b/resources/js/vue/app.js index 1852d56b5c..83da73126e 100755 --- a/resources/js/vue/app.js +++ b/resources/js/vue/app.js @@ -27,6 +27,7 @@ const app = createApp({ ProjectSitesPage: defineAsyncComponent(() => import('./components/ProjectSitesPage.vue')), SitesIdPage: defineAsyncComponent(() => import('./components/SitesIdPage.vue')), ProjectMembersPage: defineAsyncComponent(() => import('./components/ProjectMembersPage.vue')), + ProjectNotificationsPage: defineAsyncComponent(() => import('./components/ProjectNotificationsPage.vue')), UsersPage: defineAsyncComponent(() => import('./components/UsersPage.vue')), BuildFilesPage: defineAsyncComponent(() => import('./components/BuildFilesPage.vue')), BuildTargetsPage: defineAsyncComponent(() => import('./components/BuildTargetsPage.vue')), diff --git a/resources/js/vue/components/ProjectNotificationsPage.vue b/resources/js/vue/components/ProjectNotificationsPage.vue new file mode 100644 index 0000000000..79fc11e873 --- /dev/null +++ b/resources/js/vue/components/ProjectNotificationsPage.vue @@ -0,0 +1,221 @@ + + + diff --git a/resources/views/components/header.blade.php b/resources/views/components/header.blade.php old mode 100755 new mode 100644 index 189f9a8530..b6aa0d23a5 --- a/resources/views/components/header.blade.php +++ b/resources/views/components/header.blade.php @@ -210,7 +210,7 @@ @endif @if($userInProject)
  • - + Id}/notifications") }}"> Notifications
  • diff --git a/routes/web.php b/routes/web.php index 99a62b6c3f..8a756347ee 100755 --- a/routes/web.php +++ b/routes/web.php @@ -25,7 +25,6 @@ use App\Http\Controllers\ProjectSettingsController; use App\Http\Controllers\SiteController; use App\Http\Controllers\SubmissionController; -use App\Http\Controllers\SubscribeProjectController; use App\Http\Controllers\UpdateProjectLogoController; use App\Http\Controllers\UsersController; use App\Http\Controllers\ViewProjectsController; @@ -311,9 +310,17 @@ Route::match(['get', 'post'], '/profile', 'UserController@edit'); Route::permanentRedirect('/editUser.php', url('/profile')); - // TODO: (williamjallen) send the POST route to a different function - Route::get('/subscribeProject.php', SubscribeProjectController::class); - Route::post('/subscribeProject.php', SubscribeProjectController::class); + Route::get('/projects/{project_id}/notifications', 'ProjectNotificationsController@show') + ->whereNumber('project_id'); + Route::post('/projects/{project_id}/notifications', 'ProjectNotificationsController@update') + ->whereNumber('project_id'); + Route::get('/subscribeProject.php', function (Request $request) { + $projectid = $request->query('projectid'); + if (!is_numeric($projectid)) { + abort(400, 'Not a valid projectid!'); + } + return redirect("/projects/{$projectid}/notifications", 301); + }); Route::get('/manageProjectRoles.php', function (Request $request) { if (!$request->has('projectid')) { diff --git a/tests/Browser/Pages/ProjectNotificationsPageTest.php b/tests/Browser/Pages/ProjectNotificationsPageTest.php new file mode 100644 index 0000000000..9edea7c28b --- /dev/null +++ b/tests/Browser/Pages/ProjectNotificationsPageTest.php @@ -0,0 +1,130 @@ +user = User::factory()->create(); + + $this->project = $this->makePublicProject(); + $this->project->users()->attach($this->user, [ + 'role' => ProjectRole::USER, + 'emailtype' => 1, + 'emailcategory' => 2 + 4, + 'emailsuccess' => false, + 'emailmissingsites' => true, + ]); + } + + public function tearDown(): void + { + $this->project->delete(); + $this->user->delete(); + + parent::tearDown(); + } + + public function testIsProtectedByLogin(): void + { + $this->browse(function (Browser $browser): void { + $browser->visit("/projects/{$this->project->id}/notifications") + ->assertUrlIs(config('app.url') . '/login'); + }); + } + + public function testHeaderLinksToPage(): void + { + $this->browse(function (Browser $browser): void { + $browser->loginAs($this->user) + ->visit("/projects/{$this->project->id}/members") + ->waitFor('@project-members-page') + ->assertAttribute('a[href$="/notifications"]', 'href', url("/projects/{$this->project->id}/notifications")); + }); + } + + public function testShowsAndUpdatesPreferences(): void + { + $this->browse(function (Browser $browser): void { + $browser->loginAs($this->user) + ->visit("/projects/{$this->project->id}/notifications") + ->waitFor('@project-notifications-page') + ->assertMissing('@message') + ->assertMissing('@project-emails-disabled-warning') + ->assertRadioSelected('emailtype', '1') + ->assertNotChecked('@email-success') + ->assertChecked('@email-missing-sites') + ->assertChecked('@email-category-update') + ->assertChecked('@email-category-configure') + ->assertNotChecked('@email-category-warning') + ->assertNotChecked('@email-category-error') + ->assertNotChecked('@email-category-test') + ->assertNotChecked('@email-category-dynamicanalysis') + ->assertMissing('@unsaved-changes') + + ->radio('emailtype', '2') + ->check('@email-success') + ->uncheck('@email-missing-sites') + ->uncheck('@email-category-update') + ->check('@email-category-test') + ->assertVisible('@unsaved-changes') + ->click('@save-button') + + ->waitForTextIn('@message', 'Your notification preferences have been updated.') + ->assertRadioSelected('emailtype', '2') + ->assertChecked('@email-success') + ->assertNotChecked('@email-missing-sites') + ->assertNotChecked('@email-category-update') + ->assertChecked('@email-category-configure') + ->assertChecked('@email-category-test') + ->assertMissing('@unsaved-changes'); + }); + + $row = DB::table('user2project') + ->where('userid', $this->user->id) + ->where('projectid', $this->project->id) + ->first(); + self::assertNotNull($row); + self::assertSame(2, (int) $row->emailtype); + self::assertSame(4 + 32, (int) $row->emailcategory); + self::assertSame(1, (int) $row->emailsuccess); + self::assertSame(0, (int) $row->emailmissingsites); + } + + public function testShowsWarningWhenProjectEmailsDisabled(): void + { + $this->project->emailbrokensubmission = false; + $this->project->save(); + + $this->browse(function (Browser $browser): void { + $browser->loginAs($this->user) + ->visit("/projects/{$this->project->id}/notifications") + ->waitFor('@project-emails-disabled-warning') + ->assertSeeIn('@project-emails-disabled-warning', 'Contact the project administrator.'); + }); + + $this->project->users()->updateExistingPivot($this->user->id, ['role' => ProjectRole::ADMINISTRATOR]); + + $this->browse(function (Browser $browser): void { + $browser->visit("/projects/{$this->project->id}/notifications") + ->waitFor('@project-emails-disabled-warning') + ->assertSeeLink('Change the project settings.'); + }); + } +} diff --git a/tests/Feature/ProjectNotificationsTest.php b/tests/Feature/ProjectNotificationsTest.php new file mode 100644 index 0000000000..320dbf2a63 --- /dev/null +++ b/tests/Feature/ProjectNotificationsTest.php @@ -0,0 +1,205 @@ +project = $this->makePublicProject(); + $this->user = User::factory()->create(); + $this->project->users()->attach($this->user, [ + 'role' => ProjectRole::USER, + 'emailtype' => 1, + 'emailcategory' => 2 + 16 + 64, + 'emailsuccess' => true, + 'emailmissingsites' => false, + ]); + } + + /** + * @return array + */ + private function getPreferences(User $user): array + { + $row = DB::table('user2project') + ->where('userid', $user->id) + ->where('projectid', $this->project->id) + ->first(['emailtype', 'emailcategory', 'emailsuccess', 'emailmissingsites']); + self::assertNotNull($row); + + return [ + 'emailtype' => (int) $row->emailtype, + 'emailcategory' => (int) $row->emailcategory, + 'emailsuccess' => (int) $row->emailsuccess, + 'emailmissingsites' => (int) $row->emailmissingsites, + ]; + } + + /** + * @param TestResponse $response + * + * @return array + */ + private static function getProps(TestResponse $response): array + { + /** @var array $props */ + $props = $response->assertOk()->viewData('props'); + self::assertIsArray($props); + return $props; + } + + public function testRequiresLogin(): void + { + $this->get("/projects/{$this->project->id}/notifications")->assertRedirect('login'); + $this->post("/projects/{$this->project->id}/notifications", ['emailtype' => 0])->assertRedirect('login'); + } + + public function testShowsCurrentPreferences(): void + { + $this->project->emailbrokensubmission = false; + $this->project->save(); + + $props = self::getProps($this->actingAs($this->user)->get("/projects/{$this->project->id}/notifications")); + + self::assertSame($this->project->id, $props['project-id']); + self::assertFalse($props['project-emails-enabled']); + self::assertFalse($props['can-edit-project']); + self::assertSame(1, $props['email-type']); + self::assertTrue($props['email-success']); + self::assertFalse($props['email-missing-sites']); + self::assertSame(['update', 'error', 'dynamicanalysis'], $props['email-categories']); + self::assertSame('', $props['message']); + } + + public function testProjectAdminCanEditProject(): void + { + $admin = User::factory()->create(); + $this->project->users()->attach($admin, ['role' => ProjectRole::ADMINISTRATOR]); + + $props = self::getProps($this->actingAs($admin)->get("/projects/{$this->project->id}/notifications")); + + self::assertTrue($props['can-edit-project']); + self::assertTrue($props['project-emails-enabled']); + } + + public function testNonMembersCannotViewOrUpdatePreferences(): void + { + $non_member = User::factory()->create(); + + $this->actingAs($non_member) + ->get("/projects/{$this->project->id}/notifications") + ->assertForbidden(); + $this->actingAs($non_member) + ->post("/projects/{$this->project->id}/notifications", ['emailtype' => 3]) + ->assertForbidden(); + + self::assertFalse($non_member->projects()->whereKey($this->project->id)->exists()); + } + + public function testCannotViewInaccessibleProject(): void + { + $private_project = $this->makePrivateProject(); + + $this->actingAs($this->user) + ->get("/projects/{$private_project->id}/notifications") + ->assertNotFound(); + $this->actingAs($this->user) + ->post("/projects/{$private_project->id}/notifications", ['emailtype' => 3]) + ->assertNotFound(); + $this->actingAs($this->user) + ->get('/projects/123456789/notifications') + ->assertNotFound(); + } + + public function testUpdatesPreferences(): void + { + $other_user = User::factory()->create(); + $this->project->users()->attach($other_user, ['role' => ProjectRole::USER]); + $other_user_preferences = $this->getPreferences($other_user); + + $this->actingAs($this->user) + ->post("/projects/{$this->project->id}/notifications", [ + 'emailtype' => '3', + 'emailmissingsites' => '1', + 'emailcategories' => ['configure', 'warning', 'test'], + ]) + ->assertRedirect("/projects/{$this->project->id}/notifications") + ->assertSessionHas('message', 'Your notification preferences have been updated.'); + + self::assertSame([ + 'emailtype' => 3, + 'emailcategory' => 4 + 8 + 32, + 'emailsuccess' => 0, + 'emailmissingsites' => 1, + ], $this->getPreferences($this->user)); + self::assertSame($other_user_preferences, $this->getPreferences($other_user)); + + $props = self::getProps($this->actingAs($this->user)->get("/projects/{$this->project->id}/notifications")); + self::assertSame(3, $props['email-type']); + self::assertSame(['configure', 'warning', 'test'], $props['email-categories']); + self::assertSame('Your notification preferences have been updated.', $props['message']); + } + + public function testClearsAllCategories(): void + { + $this->actingAs($this->user) + ->post("/projects/{$this->project->id}/notifications", ['emailtype' => '0']) + ->assertRedirect("/projects/{$this->project->id}/notifications"); + + self::assertSame([ + 'emailtype' => 0, + 'emailcategory' => 0, + 'emailsuccess' => 0, + 'emailmissingsites' => 0, + ], $this->getPreferences($this->user)); + } + + public function testRejectsInvalidPreferences(): void + { + $original_preferences = $this->getPreferences($this->user); + + foreach ([ + [], + ['emailtype' => '4'], + ['emailtype' => 'abc'], + ['emailtype' => '1', 'emailsuccess' => 'abc'], + ['emailtype' => '1', 'emailcategories' => 'test'], + ['emailtype' => '1', 'emailcategories' => ['coverage']], + ] as $input) { + $this->actingAs($this->user) + ->post("/projects/{$this->project->id}/notifications", $input) + ->assertSessionHasErrors(); + } + + self::assertSame($original_preferences, $this->getPreferences($this->user)); + } + + public function testLegacyUrlRedirects(): void + { + $this->actingAs($this->user) + ->get("/subscribeProject.php?projectid={$this->project->id}") + ->assertRedirect("/projects/{$this->project->id}/notifications"); + $this->actingAs($this->user) + ->get('/subscribeProject.php') + ->assertStatus(400); + } +}