From 280c61462d9c33411c74307861f590f6f9d71e03 Mon Sep 17 00:00:00 2001 From: William Allen Date: Tue, 3 Feb 2026 17:50:54 -0500 Subject: [PATCH 1/5] Rename project measurements to pinnedTestMeasurements --- app/Http/Controllers/BuildController.php | 2 +- app/Http/Controllers/ManageMeasurementsController.php | 10 +++++----- .../{Measurement.php => PinnedTestMeasurement.php} | 4 ++-- app/Models/Project.php | 6 +++--- app/cdash/app/Controller/Api/QueryTests.php | 6 +++--- app/cdash/app/Controller/Api/TestDetails.php | 2 +- app/cdash/app/Controller/Api/ViewTest.php | 2 +- app/cdash/public/api/v1/index.php | 2 +- app/cdash/tests/test_managemeasurements.php | 4 ++-- tests/Browser/Pages/BuildTestsPageTest.php | 8 ++++---- 10 files changed, 23 insertions(+), 23 deletions(-) rename app/Models/{Measurement.php => PinnedTestMeasurement.php} (90%) diff --git a/app/Http/Controllers/BuildController.php b/app/Http/Controllers/BuildController.php index 7e4096916b..7f58029731 100644 --- a/app/Http/Controllers/BuildController.php +++ b/app/Http/Controllers/BuildController.php @@ -107,7 +107,7 @@ public function tests(int $build_id): View 'project-name' => $eloquent_project->name, 'build-time' => Carbon::parse($this->build->StartTime)->toIso8601String(), 'initial-filters' => $filters, - 'pinned-measurements' => $eloquent_project->measurements()->orderBy('position')->pluck('name')->toArray(), + 'pinned-measurements' => $eloquent_project->pinnedTestMeasurements()->orderBy('position')->pluck('name')->toArray(), ]); } diff --git a/app/Http/Controllers/ManageMeasurementsController.php b/app/Http/Controllers/ManageMeasurementsController.php index e0b4ec61ca..48ef93a478 100644 --- a/app/Http/Controllers/ManageMeasurementsController.php +++ b/app/Http/Controllers/ManageMeasurementsController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers; -use App\Models\Measurement; +use App\Models\PinnedTestMeasurement; use App\Models\Project as EloquentProject; use App\Utils\PageTimer; use Illuminate\Http\JsonResponse; @@ -40,7 +40,7 @@ public function apiGet(): JsonResponse // Get any measurements associated with this project's tests. $measurements_response = []; $measurements = EloquentProject::findOrFail($this->project->Id) - ->measurements() + ->pinnedTestMeasurements() ->orderBy('position', 'asc') ->get(); @@ -71,9 +71,9 @@ public function apiPost(): JsonResponse $id = (int) $measurement_data['id']; if ($id > 0) { // Update an existing measurement rather than creating a new one. - $measurement = Measurement::find($id); + $measurement = PinnedTestMeasurement::find($id); } else { - $measurement = new Measurement(); + $measurement = new PinnedTestMeasurement(); } $measurement->projectid = $this->project->Id; $measurement->name = $measurement_data['name']; @@ -107,7 +107,7 @@ public function apiDelete(): JsonResponse } $deleted = EloquentProject::findOrFail($this->project->Id) - ->measurements() + ->pinnedTestMeasurements() ->where('id', (int) request()->input('id')) ->delete(); diff --git a/app/Models/Measurement.php b/app/Models/PinnedTestMeasurement.php similarity index 90% rename from app/Models/Measurement.php rename to app/Models/PinnedTestMeasurement.php index bdbcdba72d..8fe4765c6d 100644 --- a/app/Models/Measurement.php +++ b/app/Models/PinnedTestMeasurement.php @@ -12,9 +12,9 @@ * @property string $name * @property int $position * - * @mixin Builder + * @mixin Builder */ -class Measurement extends Model +class PinnedTestMeasurement extends Model { protected $table = 'measurement'; diff --git a/app/Models/Project.php b/app/Models/Project.php index b6f84fc70b..8f0ced45c3 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -232,11 +232,11 @@ public function subprojects(?Carbon $date = null): HasMany } /** - * @return HasMany + * @return HasMany */ - public function measurements(): HasMany + public function pinnedTestMeasurements(): HasMany { - return $this->hasMany(Measurement::class, 'projectid', 'id'); + return $this->hasMany(PinnedTestMeasurement::class, 'projectid', 'id'); } /** diff --git a/app/cdash/app/Controller/Api/QueryTests.php b/app/cdash/app/Controller/Api/QueryTests.php index 413f41e51c..7f00f75295 100644 --- a/app/cdash/app/Controller/Api/QueryTests.php +++ b/app/cdash/app/Controller/Api/QueryTests.php @@ -17,7 +17,7 @@ namespace CDash\Controller\Api; -use App\Models\Measurement; +use App\Models\PinnedTestMeasurement; use App\Models\Project as EloquentProject; use App\Models\TestMeasurement; use CDash\Database; @@ -276,10 +276,10 @@ public function getResponse(): array // Get the list of extra test measurements that should be displayed on this page. $this->extraMeasurements = []; $measurements = EloquentProject::findOrFail($this->project->Id) - ->measurements() + ->pinnedTestMeasurements() ->orderBy('position') ->get(); - /** @var Measurement $measurement */ + /** @var PinnedTestMeasurement $measurement */ foreach ($measurements as $measurement) { // If we have the Processors measurement, then we should also // compute and display 'Proc Time'. diff --git a/app/cdash/app/Controller/Api/TestDetails.php b/app/cdash/app/Controller/Api/TestDetails.php index 28ba995980..881b8324d0 100644 --- a/app/cdash/app/Controller/Api/TestDetails.php +++ b/app/cdash/app/Controller/Api/TestDetails.php @@ -292,7 +292,7 @@ function () use ($query): void { // Get the list of extra test measurements that have been explicitly added to this project. $extra_measurements = EloquentProject::findOrFail($this->project->Id) - ->measurements() + ->pinnedTestMeasurements() ->orderBy('position') ->pluck('name') ->toArray(); diff --git a/app/cdash/app/Controller/Api/ViewTest.php b/app/cdash/app/Controller/Api/ViewTest.php index 2c3ffdd066..7ea71b64ba 100644 --- a/app/cdash/app/Controller/Api/ViewTest.php +++ b/app/cdash/app/Controller/Api/ViewTest.php @@ -251,7 +251,7 @@ public function getResponse() $response['hasprocessors'] = false; $processors_idx = -1; $extra_measurements = EloquentProject::findOrFail($this->project->Id) - ->measurements() + ->pinnedTestMeasurements() ->orderBy('position') ->get(); foreach ($extra_measurements as $extra_measurement) { diff --git a/app/cdash/public/api/v1/index.php b/app/cdash/public/api/v1/index.php index 604268823a..02aa3bbbb3 100644 --- a/app/cdash/public/api/v1/index.php +++ b/app/cdash/public/api/v1/index.php @@ -634,7 +634,7 @@ // This is only shown if this project is setup to display // an extra test measurement called 'Processors'. $response['showProcTime'] = EloquentProject::findOrFail($Project->Id) - ->measurements() + ->pinnedTestMeasurements() ->where('name', 'Processors') ->exists(); diff --git a/app/cdash/tests/test_managemeasurements.php b/app/cdash/tests/test_managemeasurements.php index 4f9bddaca0..4e317feda2 100644 --- a/app/cdash/tests/test_managemeasurements.php +++ b/app/cdash/tests/test_managemeasurements.php @@ -7,7 +7,7 @@ require_once __DIR__ . '/cdash_test_case.php'; -use App\Models\Measurement; +use App\Models\PinnedTestMeasurement; use App\Utils\DatabaseCleanupUtils; use CDash\Database; use GuzzleHttp\Exception\ClientException; @@ -41,7 +41,7 @@ public function __destruct() DatabaseCleanupUtils::removeBuild($this->SubProjectBuildId); } - Measurement::destroy($this->MeasurementIds); + PinnedTestMeasurement::destroy($this->MeasurementIds); } // function to validate test results returned by the API. diff --git a/tests/Browser/Pages/BuildTestsPageTest.php b/tests/Browser/Pages/BuildTestsPageTest.php index e4456f99e1..4c6b5d1c6c 100644 --- a/tests/Browser/Pages/BuildTestsPageTest.php +++ b/tests/Browser/Pages/BuildTestsPageTest.php @@ -337,7 +337,7 @@ public function testMeasurementColumns(): void 'value' => Str::uuid()->toString(), ]); - $this->project->measurements()->create([ + $this->project->pinnedTestMeasurements()->create([ 'name' => $measurement1->name, 'position' => 1, ]); @@ -362,17 +362,17 @@ public function testMeasurementColumnOrder(): void 'uuid' => Str::uuid()->toString(), ]); - $measurement1 = $this->project->measurements()->create([ + $measurement1 = $this->project->pinnedTestMeasurements()->create([ 'name' => Str::uuid()->toString(), 'position' => 1, ]); - $measurement2 = $this->project->measurements()->create([ + $measurement2 = $this->project->pinnedTestMeasurements()->create([ 'name' => Str::uuid()->toString(), 'position' => 2, ]); - $measurement3 = $this->project->measurements()->create([ + $measurement3 = $this->project->pinnedTestMeasurements()->create([ 'name' => Str::uuid()->toString(), 'position' => 3, ]); From 8fc704a747ef7f33729ac05ef6ea6ce98660fb21 Mon Sep 17 00:00:00 2001 From: William Allen Date: Tue, 3 Feb 2026 17:51:29 -0500 Subject: [PATCH 2/5] Expose pinnedTestMeasurements via GraphQL --- app/cdash/tests/CMakeLists.txt | 2 + graphql/schema.graphql | 16 ++++ phpstan-baseline.neon | 20 ++--- .../GraphQL/PinnedTestMeasurementTypeTest.php | 89 +++++++++++++++++++ 4 files changed, 117 insertions(+), 10 deletions(-) create mode 100644 tests/Feature/GraphQL/PinnedTestMeasurementTypeTest.php diff --git a/app/cdash/tests/CMakeLists.txt b/app/cdash/tests/CMakeLists.txt index 4ae7a27844..79cbf0ee73 100644 --- a/app/cdash/tests/CMakeLists.txt +++ b/app/cdash/tests/CMakeLists.txt @@ -225,6 +225,8 @@ add_feature_test_in_transaction(/Feature/GraphQL/UpdateTypeTest) add_feature_test_in_transaction(/Feature/GraphQL/UpdateFileTypeTest) +add_feature_test_in_transaction(/Feature/GraphQL/PinnedTestMeasurementTypeTest) + add_feature_test_in_transaction(/Feature/RouteAccessTest) add_feature_test_in_transaction(/Feature/Monitor) diff --git a/graphql/schema.graphql b/graphql/schema.graphql index fc9b22dc60..b87a3df3a4 100644 --- a/graphql/schema.graphql +++ b/graphql/schema.graphql @@ -306,6 +306,12 @@ type Project { # Can't return null on authorization failure due to Lighthouse bug with connections and can* directives. "Invitations to this project which have not been accepted yet." invitations: [ProjectInvitation!]! @canRoot(ability: "inviteUser") @hasMany(type: CONNECTION) @orderBy(column: "id") + + """ + Pinned test measurements are test measurements, identified by name, which should be shown + alongside Test details throughout the site. + """ + pinnedTestMeasurements: [PinnedTestMeasurement!]! @hasMany(type: CONNECTION) @orderBy(column: "position") } @@ -1142,3 +1148,13 @@ type UpdateFile @model(class: "App\\Models\\BuildUpdateFile") { status: String! @filterable } + + +type PinnedTestMeasurement { + "Unique primary key." + id: ID! + + name: String! + + position: Int! +} diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index de5b6f7599..0b0cd2107c 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1516,7 +1516,7 @@ parameters: path: app/Http/Controllers/IndexController.php - - rawMessage: 'Call to an undefined method App\Models\Project|Illuminate\Database\Eloquent\Collection::measurements().' + rawMessage: 'Call to an undefined method App\Models\Project|Illuminate\Database\Eloquent\Collection::pinnedTestMeasurements().' identifier: method.notFound count: 2 path: app/Http/Controllers/ManageMeasurementsController.php @@ -1540,31 +1540,31 @@ parameters: path: app/Http/Controllers/ManageMeasurementsController.php - - rawMessage: Cannot access property $id on App\Models\Measurement|null. + rawMessage: Cannot access property $id on App\Models\PinnedTestMeasurement|null. identifier: property.nonObject count: 1 path: app/Http/Controllers/ManageMeasurementsController.php - - rawMessage: Cannot access property $name on App\Models\Measurement|null. + rawMessage: Cannot access property $name on App\Models\PinnedTestMeasurement|null. identifier: property.nonObject count: 1 path: app/Http/Controllers/ManageMeasurementsController.php - - rawMessage: Cannot access property $position on App\Models\Measurement|null. + rawMessage: Cannot access property $position on App\Models\PinnedTestMeasurement|null. identifier: property.nonObject count: 1 path: app/Http/Controllers/ManageMeasurementsController.php - - rawMessage: Cannot access property $projectid on App\Models\Measurement|null. + rawMessage: Cannot access property $projectid on App\Models\PinnedTestMeasurement|null. identifier: property.nonObject count: 1 path: app/Http/Controllers/ManageMeasurementsController.php - - rawMessage: 'Cannot call method save() on App\Models\Measurement|null.' + rawMessage: 'Cannot call method save() on App\Models\PinnedTestMeasurement|null.' identifier: method.nonObject count: 1 path: app/Http/Controllers/ManageMeasurementsController.php @@ -7666,7 +7666,7 @@ parameters: path: app/cdash/app/Controller/Api/QueryTests.php - - rawMessage: 'Call to an undefined method App\Models\Project|Illuminate\Database\Eloquent\Collection::measurements().' + rawMessage: 'Call to an undefined method App\Models\Project|Illuminate\Database\Eloquent\Collection::pinnedTestMeasurements().' identifier: method.notFound count: 1 path: app/cdash/app/Controller/Api/QueryTests.php @@ -7918,7 +7918,7 @@ parameters: path: app/cdash/app/Controller/Api/TestDetails.php - - rawMessage: 'Call to an undefined method App\Models\Project|Illuminate\Database\Eloquent\Collection::measurements().' + rawMessage: 'Call to an undefined method App\Models\Project|Illuminate\Database\Eloquent\Collection::pinnedTestMeasurements().' identifier: method.notFound count: 1 path: app/cdash/app/Controller/Api/TestDetails.php @@ -8272,7 +8272,7 @@ parameters: path: app/cdash/app/Controller/Api/ViewTest.php - - rawMessage: 'Call to an undefined method App\Models\Project|Illuminate\Database\Eloquent\Collection::measurements().' + rawMessage: 'Call to an undefined method App\Models\Project|Illuminate\Database\Eloquent\Collection::pinnedTestMeasurements().' identifier: method.notFound count: 1 path: app/cdash/app/Controller/Api/ViewTest.php @@ -14977,7 +14977,7 @@ parameters: path: app/cdash/public/api/v1/computeClassifier.php - - rawMessage: 'Call to an undefined method App\Models\Project|Illuminate\Database\Eloquent\Collection::measurements().' + rawMessage: 'Call to an undefined method App\Models\Project|Illuminate\Database\Eloquent\Collection::pinnedTestMeasurements().' identifier: method.notFound count: 1 path: app/cdash/public/api/v1/index.php diff --git a/tests/Feature/GraphQL/PinnedTestMeasurementTypeTest.php b/tests/Feature/GraphQL/PinnedTestMeasurementTypeTest.php new file mode 100644 index 0000000000..221c477852 --- /dev/null +++ b/tests/Feature/GraphQL/PinnedTestMeasurementTypeTest.php @@ -0,0 +1,89 @@ +makePublicProject(); + + /** @var PinnedTestMeasurement $measurement1 */ + $measurement1 = $project->pinnedTestMeasurements()->create([ + 'name' => Str::uuid()->toString(), + 'position' => 3, + ]); + + /** @var PinnedTestMeasurement $measurement2 */ + $measurement2 = $project->pinnedTestMeasurements()->create([ + 'name' => Str::uuid()->toString(), + 'position' => 1, + ]); + + /** @var PinnedTestMeasurement $measurement3 */ + $measurement3 = $project->pinnedTestMeasurements()->create([ + 'name' => Str::uuid()->toString(), + 'position' => 2, + ]); + + $this->graphQL(' + query project($id: ID) { + project(id: $id) { + pinnedTestMeasurements { + edges { + node { + id + name + position + } + } + } + } + } + ', [ + 'id' => $project->id, + ])->assertExactJson([ + 'data' => [ + 'project' => [ + 'pinnedTestMeasurements' => [ + 'edges' => [ + [ + 'node' => [ + 'id' => (string) $measurement2->id, + 'name' => $measurement2->name, + 'position' => $measurement2->position, + ], + ], + [ + 'node' => [ + 'id' => (string) $measurement3->id, + 'name' => $measurement3->name, + 'position' => $measurement3->position, + ], + ], + [ + 'node' => [ + 'id' => (string) $measurement1->id, + 'name' => $measurement1->name, + 'position' => $measurement1->position, + ], + ], + ], + ], + ], + ], + ]); + } +} From 9154b984eb3b28eabd7bafff9c2b17330d8e539b Mon Sep 17 00:00:00 2001 From: William Allen Date: Tue, 3 Feb 2026 18:52:09 -0500 Subject: [PATCH 3/5] Add CreatePinnedTestMeasurement mutation --- .../Mutations/CreatePinnedTestMeasurement.php | 38 +++ app/Policies/ProjectPolicy.php | 5 + app/cdash/tests/CMakeLists.txt | 2 + graphql/schema.graphql | 17 ++ phpstan-baseline.neon | 12 + .../CreatePinnedTestMeasurementTest.php | 216 ++++++++++++++++++ 6 files changed, 290 insertions(+) create mode 100644 app/GraphQL/Mutations/CreatePinnedTestMeasurement.php create mode 100644 tests/Feature/GraphQL/Mutations/CreatePinnedTestMeasurementTest.php diff --git a/app/GraphQL/Mutations/CreatePinnedTestMeasurement.php b/app/GraphQL/Mutations/CreatePinnedTestMeasurement.php new file mode 100644 index 0000000000..e9d1337b71 --- /dev/null +++ b/app/GraphQL/Mutations/CreatePinnedTestMeasurement.php @@ -0,0 +1,38 @@ +pinnedTestMeasurements()->max('position'); + if ($nextAvailablePosition === null) { + $nextAvailablePosition = 1; + } else { + $nextAvailablePosition++; + } + + $this->pinnedTestMeasurement = $project?->pinnedTestMeasurements()->create([ + 'name' => $args['name'], + 'position' => $nextAvailablePosition, + ]); + } +} diff --git a/app/Policies/ProjectPolicy.php b/app/Policies/ProjectPolicy.php index 94e28dfe2a..8b69078317 100644 --- a/app/Policies/ProjectPolicy.php +++ b/app/Policies/ProjectPolicy.php @@ -141,6 +141,11 @@ public function leave(User $currentUser, Project $project): bool return !$this->isLdapControlledMembership($project) && $project->users()->where('id', $currentUser->id)->exists(); } + public function addPinnedTestMeasurement(User $currentUser, Project $project): bool + { + return $this->update($currentUser, $project); + } + private function isLdapControlledMembership(Project $project): bool { // If a LDAP filter has been specified and LDAP is enabled, CDash controls the entire members list. diff --git a/app/cdash/tests/CMakeLists.txt b/app/cdash/tests/CMakeLists.txt index 79cbf0ee73..5749d6fb62 100644 --- a/app/cdash/tests/CMakeLists.txt +++ b/app/cdash/tests/CMakeLists.txt @@ -276,6 +276,8 @@ add_feature_test_in_transaction(/Feature/GraphQL/Mutations/CreateGlobalInvitatio add_feature_test_in_transaction(/Feature/GraphQL/Mutations/RevokeGlobalInvitationTest) +add_feature_test_in_transaction(/Feature/GraphQL/Mutations/CreatePinnedTestMeasurementTest) + add_feature_test_in_transaction(/Feature/GlobalInvitationAcceptanceTest) add_feature_test_in_transaction(/Feature/GraphQL/GlobalInvitationTypeTest) diff --git a/graphql/schema.graphql b/graphql/schema.graphql index b87a3df3a4..b2a4b68361 100644 --- a/graphql/schema.graphql +++ b/graphql/schema.graphql @@ -111,6 +111,9 @@ type Mutation { "Delete a user." removeUser(input: RemoveUserInput! @spread): RemoveUserMutationPayload! @field(resolver: "RemoveUser") + + "Mark a test measurement as 'pinned' by name. Position is set to the next available position." + createPinnedTestMeasurement(input: CreatePinnedTestMeasurementInput! @spread): CreatePinnedTestMeasurementMutationPayload! @field(resolver: "CreatePinnedTestMeasurement") } @@ -511,6 +514,20 @@ type RemoveUserMutationPayload implements MutationPayloadInterface { } +input CreatePinnedTestMeasurementInput { + projectId: ID! + name: String! +} + + +type CreatePinnedTestMeasurementMutationPayload implements MutationPayloadInterface { + "Optional error message." + message: String + + pinnedTestMeasurement: PinnedTestMeasurement +} + + "Configure." type Configure { "Unique primary key." diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 0b0cd2107c..bbdc768070 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -270,6 +270,18 @@ parameters: count: 1 path: app/GraphQL/Mutations/CreateGlobalInvitation.php + - + rawMessage: Cannot use ++ on mixed. + identifier: postInc.type + count: 1 + path: app/GraphQL/Mutations/CreatePinnedTestMeasurement.php + + - + rawMessage: 'Parameter #1 $args (array{projectId: int, name: string}) of method App\GraphQL\Mutations\CreatePinnedTestMeasurement::mutate() should be contravariant with parameter $args (array) of method App\GraphQL\Mutations\AbstractMutation::mutate()' + identifier: method.childParameterType + count: 1 + path: app/GraphQL/Mutations/CreatePinnedTestMeasurement.php + - rawMessage: 'Parameter #1 $args (array{email: string, projectId: int, role: App\Enums\ProjectRole}) of method App\GraphQL\Mutations\InviteToProject::mutate() should be contravariant with parameter $args (array) of method App\GraphQL\Mutations\AbstractMutation::mutate()' identifier: method.childParameterType diff --git a/tests/Feature/GraphQL/Mutations/CreatePinnedTestMeasurementTest.php b/tests/Feature/GraphQL/Mutations/CreatePinnedTestMeasurementTest.php new file mode 100644 index 0000000000..c862799f98 --- /dev/null +++ b/tests/Feature/GraphQL/Mutations/CreatePinnedTestMeasurementTest.php @@ -0,0 +1,216 @@ +makeAdminUser(); + + $name = Str::uuid()->toString(); + $this->actingAs($user)->graphQL(' + mutation createPinnedTestMeasurement($input: CreatePinnedTestMeasurementInput!) { + createPinnedTestMeasurement(input: $input) { + message + pinnedTestMeasurement { + name + position + } + } + } + ', [ + 'input' => [ + 'projectId' => 1234567, + 'name' => $name, + ], + ])->assertExactJson([ + 'data' => [ + 'createPinnedTestMeasurement' => [ + 'message' => 'This action is unauthorized.', + 'pinnedTestMeasurement' => null, + ], + ], + ]); + + self::assertEmpty(PinnedTestMeasurement::all()); + } + + public function testFailsWhenNoUser(): void + { + $project = $this->makePublicProject(); + + $name = Str::uuid()->toString(); + $this->graphQL(' + mutation createPinnedTestMeasurement($input: CreatePinnedTestMeasurementInput!) { + createPinnedTestMeasurement(input: $input) { + message + pinnedTestMeasurement { + name + position + } + } + } + ', [ + 'input' => [ + 'projectId' => $project->id, + 'name' => $name, + ], + ])->assertExactJson([ + 'data' => [ + 'createPinnedTestMeasurement' => [ + 'message' => 'This action is unauthorized.', + 'pinnedTestMeasurement' => null, + ], + ], + ]); + + self::assertEmpty(PinnedTestMeasurement::all()); + } + + public function testFailsWhenBasicUser(): void + { + $project = $this->makePublicProject(); + $user = $this->makeNormalUser(); + + $name = Str::uuid()->toString(); + $this->actingAs($user)->graphQL(' + mutation createPinnedTestMeasurement($input: CreatePinnedTestMeasurementInput!) { + createPinnedTestMeasurement(input: $input) { + message + pinnedTestMeasurement { + name + position + } + } + } + ', [ + 'input' => [ + 'projectId' => $project->id, + 'name' => $name, + ], + ])->assertExactJson([ + 'data' => [ + 'createPinnedTestMeasurement' => [ + 'message' => 'This action is unauthorized.', + 'pinnedTestMeasurement' => null, + ], + ], + ]); + + self::assertEmpty(PinnedTestMeasurement::all()); + } + + public function testSucceedsWhenAdminUser(): void + { + $project = $this->makePublicProject(); + $user = $this->makeAdminUser(); + + $name = Str::uuid()->toString(); + $this->actingAs($user)->graphQL(' + mutation createPinnedTestMeasurement($input: CreatePinnedTestMeasurementInput!) { + createPinnedTestMeasurement(input: $input) { + message + pinnedTestMeasurement { + name + position + } + } + } + ', [ + 'input' => [ + 'projectId' => $project->id, + 'name' => $name, + ], + ])->assertExactJson([ + 'data' => [ + 'createPinnedTestMeasurement' => [ + 'message' => null, + 'pinnedTestMeasurement' => [ + 'name' => $name, + 'position' => 1, + ], + ], + ], + ]); + + self::assertContains($name, $project->pinnedTestMeasurements()->pluck('name')->toArray()); + } + + public function testCreatesMultipleMeasurementsInOrder(): void + { + $project = $this->makePublicProject(); + $user = $this->makeAdminUser(); + + $name1 = Str::uuid()->toString(); + $name2 = Str::uuid()->toString(); + + $this->actingAs($user)->graphQL(' + mutation createPinnedTestMeasurement($input: CreatePinnedTestMeasurementInput!) { + createPinnedTestMeasurement(input: $input) { + message + pinnedTestMeasurement { + name + position + } + } + } + ', [ + 'input' => [ + 'projectId' => $project->id, + 'name' => $name1, + ], + ])->assertExactJson([ + 'data' => [ + 'createPinnedTestMeasurement' => [ + 'message' => null, + 'pinnedTestMeasurement' => [ + 'name' => $name1, + 'position' => 1, + ], + ], + ], + ]); + + $this->actingAs($user)->graphQL(' + mutation createPinnedTestMeasurement($input: CreatePinnedTestMeasurementInput!) { + createPinnedTestMeasurement(input: $input) { + message + pinnedTestMeasurement { + name + position + } + } + } + ', [ + 'input' => [ + 'projectId' => $project->id, + 'name' => $name2, + ], + ])->assertExactJson([ + 'data' => [ + 'createPinnedTestMeasurement' => [ + 'message' => null, + 'pinnedTestMeasurement' => [ + 'name' => $name2, + 'position' => 2, + ], + ], + ], + ]); + + self::assertContains($name1, $project->pinnedTestMeasurements()->pluck('name')->toArray()); + self::assertContains($name2, $project->pinnedTestMeasurements()->pluck('name')->toArray()); + } +} From 64722d5ae2fd5c018ec53bb6122495b046b6f589 Mon Sep 17 00:00:00 2001 From: William Allen Date: Tue, 3 Feb 2026 19:21:49 -0500 Subject: [PATCH 4/5] Add DeletePinnedTestMeasurement mutation --- .../Mutations/CreatePinnedTestMeasurement.php | 2 +- .../Mutations/DeletePinnedTestMeasurement.php | 25 ++++ app/Models/PinnedTestMeasurement.php | 2 +- app/Policies/ProjectPolicy.php | 7 +- app/cdash/tests/CMakeLists.txt | 2 + graphql/schema.graphql | 14 ++ .../DeletePinnedTestMeasurementTest.php | 132 ++++++++++++++++++ 7 files changed, 181 insertions(+), 3 deletions(-) create mode 100644 app/GraphQL/Mutations/DeletePinnedTestMeasurement.php create mode 100644 tests/Feature/GraphQL/Mutations/DeletePinnedTestMeasurementTest.php diff --git a/app/GraphQL/Mutations/CreatePinnedTestMeasurement.php b/app/GraphQL/Mutations/CreatePinnedTestMeasurement.php index e9d1337b71..6ae5f7522d 100644 --- a/app/GraphQL/Mutations/CreatePinnedTestMeasurement.php +++ b/app/GraphQL/Mutations/CreatePinnedTestMeasurement.php @@ -21,7 +21,7 @@ final class CreatePinnedTestMeasurement extends AbstractMutation protected function mutate(array $args): void { $project = Project::find((int) $args['projectId']); - Gate::authorize('addPinnedTestMeasurement', $project); + Gate::authorize('createPinnedTestMeasurement', $project); $nextAvailablePosition = $project?->pinnedTestMeasurements()->max('position'); if ($nextAvailablePosition === null) { diff --git a/app/GraphQL/Mutations/DeletePinnedTestMeasurement.php b/app/GraphQL/Mutations/DeletePinnedTestMeasurement.php new file mode 100644 index 0000000000..06dd77fd1e --- /dev/null +++ b/app/GraphQL/Mutations/DeletePinnedTestMeasurement.php @@ -0,0 +1,25 @@ +project); + + $measurement->delete(); + } +} diff --git a/app/Models/PinnedTestMeasurement.php b/app/Models/PinnedTestMeasurement.php index 8fe4765c6d..e0c163e291 100644 --- a/app/Models/PinnedTestMeasurement.php +++ b/app/Models/PinnedTestMeasurement.php @@ -37,6 +37,6 @@ class PinnedTestMeasurement extends Model */ public function project(): BelongsTo { - return $this->belongsTo(Project::class, 'id', 'projectid'); + return $this->belongsTo(Project::class, 'projectid'); } } diff --git a/app/Policies/ProjectPolicy.php b/app/Policies/ProjectPolicy.php index 8b69078317..ad34eeb5e2 100644 --- a/app/Policies/ProjectPolicy.php +++ b/app/Policies/ProjectPolicy.php @@ -141,7 +141,12 @@ public function leave(User $currentUser, Project $project): bool return !$this->isLdapControlledMembership($project) && $project->users()->where('id', $currentUser->id)->exists(); } - public function addPinnedTestMeasurement(User $currentUser, Project $project): bool + public function createPinnedTestMeasurement(User $currentUser, Project $project): bool + { + return $this->update($currentUser, $project); + } + + public function deletePinnedTestMeasurement(User $currentUser, Project $project): bool { return $this->update($currentUser, $project); } diff --git a/app/cdash/tests/CMakeLists.txt b/app/cdash/tests/CMakeLists.txt index 5749d6fb62..50b3fd7bd8 100644 --- a/app/cdash/tests/CMakeLists.txt +++ b/app/cdash/tests/CMakeLists.txt @@ -278,6 +278,8 @@ add_feature_test_in_transaction(/Feature/GraphQL/Mutations/RevokeGlobalInvitatio add_feature_test_in_transaction(/Feature/GraphQL/Mutations/CreatePinnedTestMeasurementTest) +add_feature_test_in_transaction(/Feature/GraphQL/Mutations/DeletePinnedTestMeasurementTest) + add_feature_test_in_transaction(/Feature/GlobalInvitationAcceptanceTest) add_feature_test_in_transaction(/Feature/GraphQL/GlobalInvitationTypeTest) diff --git a/graphql/schema.graphql b/graphql/schema.graphql index b2a4b68361..5896c924f7 100644 --- a/graphql/schema.graphql +++ b/graphql/schema.graphql @@ -114,6 +114,9 @@ type Mutation { "Mark a test measurement as 'pinned' by name. Position is set to the next available position." createPinnedTestMeasurement(input: CreatePinnedTestMeasurementInput! @spread): CreatePinnedTestMeasurementMutationPayload! @field(resolver: "CreatePinnedTestMeasurement") + + "Remove a pinned test measurement. Does not re-number positions." + deletePinnedTestMeasurement(input: DeletePinnedTestMeasurementInput! @spread): DeletePinnedTestMeasurementMutationPayload! @field(resolver: "DeletePinnedTestMeasurement") } @@ -528,6 +531,17 @@ type CreatePinnedTestMeasurementMutationPayload implements MutationPayloadInterf } +input DeletePinnedTestMeasurementInput { + id: ID! +} + + +type DeletePinnedTestMeasurementMutationPayload implements MutationPayloadInterface { + "Optional error message." + message: String +} + + "Configure." type Configure { "Unique primary key." diff --git a/tests/Feature/GraphQL/Mutations/DeletePinnedTestMeasurementTest.php b/tests/Feature/GraphQL/Mutations/DeletePinnedTestMeasurementTest.php new file mode 100644 index 0000000000..6d76d57c29 --- /dev/null +++ b/tests/Feature/GraphQL/Mutations/DeletePinnedTestMeasurementTest.php @@ -0,0 +1,132 @@ +makeAdminUser(); + + $this->actingAs($user)->graphQL(' + mutation deletePinnedTestMeasurement($input: DeletePinnedTestMeasurementInput!) { + deletePinnedTestMeasurement(input: $input) { + message + } + } + ', [ + 'input' => [ + 'id' => 123456789, + ], + ])->assertExactJson([ + 'data' => [ + 'deletePinnedTestMeasurement' => [ + 'message' => 'This action is unauthorized.', + ], + ], + ]); + } + + public function testFailsWhenNoUser(): void + { + $project = $this->makePublicProject(); + + /** @var PinnedTestMeasurement $measurement */ + $measurement = $project->pinnedTestMeasurements()->create([ + 'name' => Str::uuid()->toString(), + 'position' => 1, + ]); + + $this->graphQL(' + mutation deletePinnedTestMeasurement($input: DeletePinnedTestMeasurementInput!) { + deletePinnedTestMeasurement(input: $input) { + message + } + } + ', [ + 'input' => [ + 'id' => $measurement->id, + ], + ])->assertExactJson([ + 'data' => [ + 'deletePinnedTestMeasurement' => [ + 'message' => 'This action is unauthorized.', + ], + ], + ]); + self::assertDatabaseHas(PinnedTestMeasurement::class, ['id' => $measurement->id]); + } + + public function testFailsWhenBasicUser(): void + { + $project = $this->makePublicProject(); + $user = $this->makeNormalUser(); + + /** @var PinnedTestMeasurement $measurement */ + $measurement = $project->pinnedTestMeasurements()->create([ + 'name' => Str::uuid()->toString(), + 'position' => 1, + ]); + + $this->actingAs($user)->graphQL(' + mutation deletePinnedTestMeasurement($input: DeletePinnedTestMeasurementInput!) { + deletePinnedTestMeasurement(input: $input) { + message + } + } + ', [ + 'input' => [ + 'id' => $measurement->id, + ], + ])->assertExactJson([ + 'data' => [ + 'deletePinnedTestMeasurement' => [ + 'message' => 'This action is unauthorized.', + ], + ], + ]); + self::assertDatabaseHas(PinnedTestMeasurement::class, ['id' => $measurement->id]); + } + + public function testSucceedsWhenAdminUser(): void + { + $project = $this->makePublicProject(); + $user = $this->makeAdminUser(); + + /** @var PinnedTestMeasurement $measurement */ + $measurement = $project->pinnedTestMeasurements()->create([ + 'name' => Str::uuid()->toString(), + 'position' => 1, + ]); + + $this->actingAs($user)->graphQL(' + mutation deletePinnedTestMeasurement($input: DeletePinnedTestMeasurementInput!) { + deletePinnedTestMeasurement(input: $input) { + message + } + } + ', [ + 'input' => [ + 'id' => $measurement->id, + ], + ])->assertExactJson([ + 'data' => [ + 'deletePinnedTestMeasurement' => [ + 'message' => null, + ], + ], + ]); + self::assertEmpty(PinnedTestMeasurement::all()); + } +} From 6a2556d5ad42b719a47a371d0f09780adc5abb8b Mon Sep 17 00:00:00 2001 From: William Allen Date: Wed, 4 Feb 2026 09:43:39 -0500 Subject: [PATCH 5/5] Add UpdatePinnedTestMeasurementOrder mutation --- .../Mutations/DeletePinnedTestMeasurement.php | 2 +- .../UpdatePinnedTestMeasurementOrder.php | 58 ++++ app/Policies/ProjectPolicy.php | 5 + app/cdash/tests/CMakeLists.txt | 2 + graphql/schema.graphql | 24 ++ phpstan-baseline.neon | 36 +++ .../UpdatePinnedTestMeasurementOrderTest.php | 296 ++++++++++++++++++ 7 files changed, 422 insertions(+), 1 deletion(-) create mode 100644 app/GraphQL/Mutations/UpdatePinnedTestMeasurementOrder.php create mode 100644 tests/Feature/GraphQL/Mutations/UpdatePinnedTestMeasurementOrderTest.php diff --git a/app/GraphQL/Mutations/DeletePinnedTestMeasurement.php b/app/GraphQL/Mutations/DeletePinnedTestMeasurement.php index 06dd77fd1e..a7d08003ed 100644 --- a/app/GraphQL/Mutations/DeletePinnedTestMeasurement.php +++ b/app/GraphQL/Mutations/DeletePinnedTestMeasurement.php @@ -20,6 +20,6 @@ protected function mutate(array $args): void Gate::authorize('deletePinnedTestMeasurement', $measurement?->project); - $measurement->delete(); + $measurement?->delete(); } } diff --git a/app/GraphQL/Mutations/UpdatePinnedTestMeasurementOrder.php b/app/GraphQL/Mutations/UpdatePinnedTestMeasurementOrder.php new file mode 100644 index 0000000000..95e55268cf --- /dev/null +++ b/app/GraphQL/Mutations/UpdatePinnedTestMeasurementOrder.php @@ -0,0 +1,58 @@ + */ + public ?Collection $pinnedTestMeasurements = null; + + /** + * @param array{ + * projectId: int, + * pinnedTestMeasurementIds: array, + * } $args + */ + protected function mutate(array $args): void + { + $project = Project::find((int) $args['projectId']); + Gate::authorize('updatePinnedTestMeasurementOrder', $project); + + $projectMeasurementIds = $project?->pinnedTestMeasurements()->pluck('id'); + $newOrder = collect($args['pinnedTestMeasurementIds']); + + if ($projectMeasurementIds->diff($newOrder)->isNotEmpty()) { + throw new Exception('IDs for all PinnedTestMeasurements must be provided.'); + } + + if ($newOrder->count() !== $projectMeasurementIds->count()) { + throw new Exception('Provided set cannot contain duplicate IDs.'); + } + + if ($newOrder->isEmpty()) { + throw new Exception("Can't order an empty set."); + } + + // We start at the previous maximum ID + 1 to guarantee that there are never any conflicts. + // Only the relative order matters, so we don't care if the minimum position is now 1. + $position = (int) $project?->pinnedTestMeasurements()->max('position') + 1; + foreach ($newOrder as $id) { + /** @var PinnedTestMeasurement $measurement */ + $measurement = $project?->pinnedTestMeasurements()->findOrFail((int) $id); + + $measurement->position = $position; + $measurement->save(); + $position++; + } + + $this->pinnedTestMeasurements = $project?->pinnedTestMeasurements()->orderBy('position')->get(); + } +} diff --git a/app/Policies/ProjectPolicy.php b/app/Policies/ProjectPolicy.php index ad34eeb5e2..90ff5535bb 100644 --- a/app/Policies/ProjectPolicy.php +++ b/app/Policies/ProjectPolicy.php @@ -151,6 +151,11 @@ public function deletePinnedTestMeasurement(User $currentUser, Project $project) return $this->update($currentUser, $project); } + public function updatePinnedTestMeasurementOrder(User $currentUser, Project $project): bool + { + return $this->update($currentUser, $project); + } + private function isLdapControlledMembership(Project $project): bool { // If a LDAP filter has been specified and LDAP is enabled, CDash controls the entire members list. diff --git a/app/cdash/tests/CMakeLists.txt b/app/cdash/tests/CMakeLists.txt index 50b3fd7bd8..c27fef351c 100644 --- a/app/cdash/tests/CMakeLists.txt +++ b/app/cdash/tests/CMakeLists.txt @@ -280,6 +280,8 @@ add_feature_test_in_transaction(/Feature/GraphQL/Mutations/CreatePinnedTestMeasu add_feature_test_in_transaction(/Feature/GraphQL/Mutations/DeletePinnedTestMeasurementTest) +add_feature_test_in_transaction(/Feature/GraphQL/Mutations/UpdatePinnedTestMeasurementOrderTest) + add_feature_test_in_transaction(/Feature/GlobalInvitationAcceptanceTest) add_feature_test_in_transaction(/Feature/GraphQL/GlobalInvitationTypeTest) diff --git a/graphql/schema.graphql b/graphql/schema.graphql index 5896c924f7..e772d29f8c 100644 --- a/graphql/schema.graphql +++ b/graphql/schema.graphql @@ -117,6 +117,12 @@ type Mutation { "Remove a pinned test measurement. Does not re-number positions." deletePinnedTestMeasurement(input: DeletePinnedTestMeasurementInput! @spread): DeletePinnedTestMeasurementMutationPayload! @field(resolver: "DeletePinnedTestMeasurement") + + """ + Reorder the pinned measurements. Note: positions will be sequential, starting at 1 + the + previous maximum position. Only the relative order is guaranteed. + """ + updatePinnedTestMeasurementOrder(input: UpdatePinnedTestMeasurementOrderInput! @spread): UpdatePinnedTestMeasurementOrderMutationPayload! @field(resolver: "UpdatePinnedTestMeasurementOrder") } @@ -542,6 +548,23 @@ type DeletePinnedTestMeasurementMutationPayload implements MutationPayloadInterf } +input UpdatePinnedTestMeasurementOrderInput { + projectId: ID! + + "A list of PinnedTestMeasurement IDs. Relative position will be set based on position in the list." + pinnedTestMeasurementIds: [ID!]! +} + + +type UpdatePinnedTestMeasurementOrderMutationPayload implements MutationPayloadInterface { + "Optional error message." + message: String + + "The new list of pinned measurements." + pinnedTestMeasurements: [PinnedTestMeasurement!] +} + + "Configure." type Configure { "Unique primary key." @@ -1187,5 +1210,6 @@ type PinnedTestMeasurement { name: String! + "Note: Only the relative positions of PinnedTestMeasurements are guaranteed." position: Int! } diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index bbdc768070..5f200f9711 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -282,6 +282,12 @@ parameters: count: 1 path: app/GraphQL/Mutations/CreatePinnedTestMeasurement.php + - + rawMessage: 'Parameter #1 $args (array{id: int}) of method App\GraphQL\Mutations\DeletePinnedTestMeasurement::mutate() should be contravariant with parameter $args (array) of method App\GraphQL\Mutations\AbstractMutation::mutate()' + identifier: method.childParameterType + count: 1 + path: app/GraphQL/Mutations/DeletePinnedTestMeasurement.php + - rawMessage: 'Parameter #1 $args (array{email: string, projectId: int, role: App\Enums\ProjectRole}) of method App\GraphQL\Mutations\InviteToProject::mutate() should be contravariant with parameter $args (array) of method App\GraphQL\Mutations\AbstractMutation::mutate()' identifier: method.childParameterType @@ -324,6 +330,36 @@ parameters: count: 1 path: app/GraphQL/Mutations/UnclaimSite.php + - + rawMessage: 'Cannot call method count() on Illuminate\Support\Collection<(int|string), mixed>|null.' + identifier: method.nonObject + count: 1 + path: app/GraphQL/Mutations/UpdatePinnedTestMeasurementOrder.php + + - + rawMessage: 'Cannot call method diff() on Illuminate\Support\Collection<(int|string), mixed>|null.' + identifier: method.nonObject + count: 1 + path: app/GraphQL/Mutations/UpdatePinnedTestMeasurementOrder.php + + - + rawMessage: Cannot cast mixed to int. + identifier: cast.int + count: 1 + path: app/GraphQL/Mutations/UpdatePinnedTestMeasurementOrder.php + + - + rawMessage: 'Parameter #1 $args (array{projectId: int, pinnedTestMeasurementIds: array}) of method App\GraphQL\Mutations\UpdatePinnedTestMeasurementOrder::mutate() should be contravariant with parameter $args (array) of method App\GraphQL\Mutations\AbstractMutation::mutate()' + identifier: method.childParameterType + count: 1 + path: app/GraphQL/Mutations/UpdatePinnedTestMeasurementOrder.php + + - + rawMessage: 'Property App\GraphQL\Mutations\UpdatePinnedTestMeasurementOrder::$pinnedTestMeasurements with generic class Illuminate\Support\Collection does not specify its types: TKey, TValue' + identifier: missingType.generics + count: 1 + path: app/GraphQL/Mutations/UpdatePinnedTestMeasurementOrder.php + - rawMessage: 'Parameter #1 $args (array{siteId: int, description: string}) of method App\GraphQL\Mutations\UpdateSiteDescription::mutate() should be contravariant with parameter $args (array) of method App\GraphQL\Mutations\AbstractMutation::mutate()' identifier: method.childParameterType diff --git a/tests/Feature/GraphQL/Mutations/UpdatePinnedTestMeasurementOrderTest.php b/tests/Feature/GraphQL/Mutations/UpdatePinnedTestMeasurementOrderTest.php new file mode 100644 index 0000000000..99a15bfe3e --- /dev/null +++ b/tests/Feature/GraphQL/Mutations/UpdatePinnedTestMeasurementOrderTest.php @@ -0,0 +1,296 @@ +makePublicProject(); + $project2 = $this->makePublicProject(); + $user = $this->makeAdminUser(); + + $measurement1 = $project1->pinnedTestMeasurements()->create([ + 'name' => Str::uuid()->toString(), + 'position' => 1, + ]); + $measurement2 = $project2->pinnedTestMeasurements()->create([ + 'name' => Str::uuid()->toString(), + 'position' => 1, + ]); + + $this->actingAs($user)->graphQL(' + mutation updatePinnedTestMeasurementOrder($input: UpdatePinnedTestMeasurementOrderInput!) { + updatePinnedTestMeasurementOrder(input: $input) { + message + pinnedTestMeasurements { + id + name + position + } + } + } + ', [ + 'input' => [ + 'projectId' => $project1->id, + 'pinnedTestMeasurementIds' => [$measurement2->id], + ], + ])->assertExactJson([ + 'data' => [ + 'updatePinnedTestMeasurementOrder' => [ + 'message' => 'IDs for all PinnedTestMeasurements must be provided.', + 'pinnedTestMeasurements' => null, + ], + ], + ]); + } + + public function testFailsWhenMissingIds(): void + { + $project = $this->makePublicProject(); + $user = $this->makeAdminUser(); + + $measurement1 = $project->pinnedTestMeasurements()->create([ + 'name' => Str::uuid()->toString(), + 'position' => 1, + ]); + $measurement2 = $project->pinnedTestMeasurements()->create([ + 'name' => Str::uuid()->toString(), + 'position' => 2, + ]); + + $this->actingAs($user)->graphQL(' + mutation updatePinnedTestMeasurementOrder($input: UpdatePinnedTestMeasurementOrderInput!) { + updatePinnedTestMeasurementOrder(input: $input) { + message + pinnedTestMeasurements { + id + name + position + } + } + } + ', [ + 'input' => [ + 'projectId' => $project->id, + 'pinnedTestMeasurementIds' => [$measurement1->id], + ], + ])->assertExactJson([ + 'data' => [ + 'updatePinnedTestMeasurementOrder' => [ + 'message' => 'IDs for all PinnedTestMeasurements must be provided.', + 'pinnedTestMeasurements' => null, + ], + ], + ]); + + self::assertSame(1, $measurement1->fresh()?->position); + self::assertSame(2, $measurement2->fresh()?->position); + } + + public function testFailsWhenNoMeasurements(): void + { + $project = $this->makePublicProject(); + $user = $this->makeAdminUser(); + + $this->actingAs($user)->graphQL(' + mutation updatePinnedTestMeasurementOrder($input: UpdatePinnedTestMeasurementOrderInput!) { + updatePinnedTestMeasurementOrder(input: $input) { + message + pinnedTestMeasurements { + id + name + position + } + } + } + ', [ + 'input' => [ + 'projectId' => $project->id, + 'pinnedTestMeasurementIds' => [], + ], + ])->assertExactJson([ + 'data' => [ + 'updatePinnedTestMeasurementOrder' => [ + 'message' => "Can't order an empty set.", + 'pinnedTestMeasurements' => null, + ], + ], + ]); + } + + public function testFailsWhenAnonymousUser(): void + { + $project = $this->makePublicProject(); + $measurement = $project->pinnedTestMeasurements()->create([ + 'name' => Str::uuid()->toString(), + 'position' => 1, + ]); + + $this->graphQL(' + mutation updatePinnedTestMeasurementOrder($input: UpdatePinnedTestMeasurementOrderInput!) { + updatePinnedTestMeasurementOrder(input: $input) { + message + pinnedTestMeasurements { + id + name + position + } + } + } + ', [ + 'input' => [ + 'projectId' => $project->id, + 'pinnedTestMeasurementIds' => [$measurement->id], + ], + ])->assertExactJson([ + 'data' => [ + 'updatePinnedTestMeasurementOrder' => [ + 'message' => 'This action is unauthorized.', + 'pinnedTestMeasurements' => null, + ], + ], + ]); + + self::assertSame(1, $measurement->fresh()?->position); + } + + public function testFailsWhenNormalUser(): void + { + $project = $this->makePublicProject(); + $user = $this->makeNormalUser(); + $measurement = $project->pinnedTestMeasurements()->create([ + 'name' => Str::uuid()->toString(), + 'position' => 1, + ]); + + $this->actingAs($user)->graphQL(' + mutation updatePinnedTestMeasurementOrder($input: UpdatePinnedTestMeasurementOrderInput!) { + updatePinnedTestMeasurementOrder(input: $input) { + message + pinnedTestMeasurements { + id + name + position + } + } + } + ', [ + 'input' => [ + 'projectId' => $project->id, + 'pinnedTestMeasurementIds' => [$measurement->id], + ], + ])->assertExactJson([ + 'data' => [ + 'updatePinnedTestMeasurementOrder' => [ + 'message' => 'This action is unauthorized.', + 'pinnedTestMeasurements' => null, + ], + ], + ]); + + self::assertSame(1, $measurement->fresh()?->position); + } + + public function testFailsWithDuplicateIds(): void + { + $project = $this->makePublicProject(); + $user = $this->makeAdminUser(); + + $measurement = $project->pinnedTestMeasurements()->create([ + 'name' => Str::uuid()->toString(), + 'position' => 1, + ]); + + $this->actingAs($user)->graphQL(' + mutation updatePinnedTestMeasurementOrder($input: UpdatePinnedTestMeasurementOrderInput!) { + updatePinnedTestMeasurementOrder(input: $input) { + message + pinnedTestMeasurements { + id + name + position + } + } + } + ', [ + 'input' => [ + 'projectId' => $project->id, + 'pinnedTestMeasurementIds' => [$measurement->id, $measurement->id], + ], + ])->assertExactJson([ + 'data' => [ + 'updatePinnedTestMeasurementOrder' => [ + 'message' => 'Provided set cannot contain duplicate IDs.', + 'pinnedTestMeasurements' => null, + ], + ], + ]); + + self::assertSame(1, $measurement->fresh()?->position); + } + + public function testSucceedsWhenAdminUser(): void + { + $project = $this->makePublicProject(); + $user = $this->makeAdminUser(); + + $measurement1 = $project->pinnedTestMeasurements()->create([ + 'name' => 'Measurement 1', + 'position' => 1, + ]); + $measurement2 = $project->pinnedTestMeasurements()->create([ + 'name' => 'Measurement 2', + 'position' => 2, + ]); + + $this->actingAs($user)->graphQL(' + mutation updatePinnedTestMeasurementOrder($input: UpdatePinnedTestMeasurementOrderInput!) { + updatePinnedTestMeasurementOrder(input: $input) { + message + pinnedTestMeasurements { + id + name + position + } + } + } + ', [ + 'input' => [ + 'projectId' => $project->id, + 'pinnedTestMeasurementIds' => [$measurement2->id, $measurement1->id], + ], + ])->assertExactJson([ + 'data' => [ + 'updatePinnedTestMeasurementOrder' => [ + 'message' => null, + 'pinnedTestMeasurements' => [ + [ + 'id' => (string) $measurement2->id, + 'name' => 'Measurement 2', + 'position' => 3, + ], + [ + 'id' => (string) $measurement1->id, + 'name' => 'Measurement 1', + 'position' => 4, + ], + ], + ], + ], + ]); + + self::assertSame(3, $measurement2->fresh()?->position); + self::assertSame(4, $measurement1->fresh()?->position); + } +}