diff --git a/app/GraphQL/Mutations/CreatePinnedTestMeasurement.php b/app/GraphQL/Mutations/CreatePinnedTestMeasurement.php index fbf8efae10..6ee73b3490 100644 --- a/app/GraphQL/Mutations/CreatePinnedTestMeasurement.php +++ b/app/GraphQL/Mutations/CreatePinnedTestMeasurement.php @@ -4,6 +4,7 @@ namespace App\GraphQL\Mutations; +use App\Exceptions\GraphQLMutationException; use App\Models\PinnedTestMeasurement; use App\Models\Project; use Illuminate\Support\Facades\Gate; @@ -18,12 +19,18 @@ final class CreatePinnedTestMeasurement extends AbstractMutation * projectId: int, * name: string, * } $args + * + * @throws GraphQLMutationException */ public function __invoke(null $_, array $args): self { $project = Project::find((int) $args['projectId']); Gate::authorize('createPinnedTestMeasurement', $project); + if ($project?->pinnedTestMeasurements()->where('name', $args['name'])->exists() === true) { + throw new GraphQLMutationException('This test measurement is already pinned.'); + } + $nextAvailablePosition = $project?->pinnedTestMeasurements()->max('position'); if ($nextAvailablePosition === null) { $nextAvailablePosition = 1; diff --git a/database/migrations/2026_10_25_134934_measurement_unique_name.php b/database/migrations/2026_10_25_134934_measurement_unique_name.php new file mode 100644 index 0000000000..65dd309d91 --- /dev/null +++ b/database/migrations/2026_10_25_134934_measurement_unique_name.php @@ -0,0 +1,25 @@ + (b.position, b.id) + '); + + DB::statement('CREATE UNIQUE INDEX ON measurement (projectid, name)'); + } + + public function down(): void + { + } +}; diff --git a/tests/Feature/GraphQL/Mutations/CreatePinnedTestMeasurementTest.php b/tests/Feature/GraphQL/Mutations/CreatePinnedTestMeasurementTest.php index e355a66990..d1509218d4 100644 --- a/tests/Feature/GraphQL/Mutations/CreatePinnedTestMeasurementTest.php +++ b/tests/Feature/GraphQL/Mutations/CreatePinnedTestMeasurementTest.php @@ -195,4 +195,79 @@ public function testCreatesMultipleMeasurementsInOrder(): void self::assertContains($name1, $project->pinnedTestMeasurements()->pluck('name')->toArray()); self::assertContains($name2, $project->pinnedTestMeasurements()->pluck('name')->toArray()); } + + public function testFailsWhenDuplicateName(): void + { + $project = $this->makePublicProject(); + $user = User::factory()->adminUser()->create(); + + $name = Str::uuid()->toString(); + $project->pinnedTestMeasurements()->create([ + 'name' => $name, + 'position' => 1, + ]); + + $this->actingAs($user)->graphQL(' + mutation createPinnedTestMeasurement($input: CreatePinnedTestMeasurementInput!) { + createPinnedTestMeasurement(input: $input) { + message + pinnedTestMeasurement { + name + position + } + } + } + ', [ + 'input' => [ + 'projectId' => $project->id, + 'name' => $name, + ], + ])->assertJsonPath('data.createPinnedTestMeasurement', null) + ->assertGraphQLErrorMessage('This test measurement is already pinned.'); + + self::assertSame([$name], $project->pinnedTestMeasurements()->pluck('name')->toArray()); + } + + public function testAllowsSameNameInDifferentProjects(): void + { + $project1 = $this->makePublicProject(); + $project2 = $this->makePublicProject(); + $user = User::factory()->adminUser()->create(); + + $name = Str::uuid()->toString(); + $project1->pinnedTestMeasurements()->create([ + 'name' => $name, + 'position' => 1, + ]); + + $this->actingAs($user)->graphQL(' + mutation createPinnedTestMeasurement($input: CreatePinnedTestMeasurementInput!) { + createPinnedTestMeasurement(input: $input) { + message + pinnedTestMeasurement { + name + position + } + } + } + ', [ + 'input' => [ + 'projectId' => $project2->id, + 'name' => $name, + ], + ])->assertExactJson([ + 'data' => [ + 'createPinnedTestMeasurement' => [ + 'message' => null, + 'pinnedTestMeasurement' => [ + 'name' => $name, + 'position' => 1, + ], + ], + ], + ]); + + self::assertSame([$name], $project1->pinnedTestMeasurements()->pluck('name')->toArray()); + self::assertSame([$name], $project2->pinnedTestMeasurements()->pluck('name')->toArray()); + } }