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: |
-
-
-
-
-
-
-
- |
-
-
-|
-
- |
-
-
-
-
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 @@
+
+
+
+ {{ message }}
+
+
+
+
+
+
+
+
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);
+ }
+}