From e55967e9ac4804bcad2df60ab60b01a292b676b8 Mon Sep 17 00:00:00 2001 From: HolgerHatGarKeineNode <123783602+HolgerHatGarKeineNode@users.noreply.github.com> Date: Mon, 15 Jun 2026 21:28:01 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Add=20`removeFromMine`=20functional?= =?UTF-8?q?ity=20to=20Meetups=20API=20for=20removing=20meetups=20from=20a?= =?UTF-8?q?=20user's=20"My=20Meetups"=20list?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 🔒 Introduce `removeFromMine` policy for authenticated users to remove meetups. - ✏️ Add `removeFromMine` method in `MeetupController` with idempotent handling. - ✨ Add `removeMember` utility in `Meetup` model for managing pivot relationships. - 🧪 Add feature tests for `removeFromMine`, covering idempotency, permissions, and unknown slugs. - 🌐 Register `removeFromMine` route in API and link it to `MeetupController`. --- app/Http/Controllers/Api/MeetupController.php | 18 ++++++ app/Models/Meetup.php | 11 ++++ app/Policies/MeetupPolicy.php | 11 ++++ routes/api.php | 1 + tests/Feature/Api/MeetupWriteApiTest.php | 58 +++++++++++++++++++ 5 files changed, 99 insertions(+) diff --git a/app/Http/Controllers/Api/MeetupController.php b/app/Http/Controllers/Api/MeetupController.php index 9df16ac..ac0b90f 100644 --- a/app/Http/Controllers/Api/MeetupController.php +++ b/app/Http/Controllers/Api/MeetupController.php @@ -144,6 +144,24 @@ class MeetupController extends Controller : \Symfony\Component\HttpFoundation\Response::HTTP_OK); } + /** + * Meetup aus „Meine Meetups" entfernen + * + * Entfernt ein Meetup aus der „Meine Meetups"-Liste des authentifizierten Nutzers + * (löst die meetup_user-Pivot-Mitgliedschaft). Die Stammdaten des Meetups bleiben + * erhalten — Gegenstück zu addToMine(). Idempotent: war das Meetup nicht (mehr) + * zugeordnet, bleibt die Antwort 200 OK. + */ + #[Response(status: 401, description: 'Nicht authentifiziert.')] + public function removeFromMine(Request $request, Meetup $meetup): MeetupResource + { + Gate::authorize('removeFromMine', $meetup); + + $meetup->removeMember($request->user()); + + return MeetupResource::make($meetup); + } + /** * Eigenes Meetup anzeigen * diff --git a/app/Models/Meetup.php b/app/Models/Meetup.php index 8d61f01..647ae1b 100644 --- a/app/Models/Meetup.php +++ b/app/Models/Meetup.php @@ -202,6 +202,17 @@ class Meetup extends Model implements HasMedia return ! $wasMember; } + /** + * Den Nutzer wieder aus „Meine Meetups" entfernen (löst die meetup_user-Pivot). + * Idempotent: war der Nutzer kein Mitglied, passiert nichts. Gibt true zurück, + * wenn tatsächlich eine Zuordnung gelöst wurde (false = war kein Mitglied). + * Gegenstück zu {@see addMember()}; die Stammdaten bleiben unberührt. + */ + public function removeMember(User $user): bool + { + return $this->users()->detach($user->getKey()) > 0; + } + /** * RateLimiter-Key für Meetup-Stammdaten-Updates über das Portal-Frontend. */ diff --git a/app/Policies/MeetupPolicy.php b/app/Policies/MeetupPolicy.php index e793eb2..96ef3a9 100644 --- a/app/Policies/MeetupPolicy.php +++ b/app/Policies/MeetupPolicy.php @@ -47,6 +47,17 @@ class MeetupPolicy return true; } + /** + * Ein Meetup wieder aus „Meine Meetups" entfernen (löst die meetup_user-Pivot + * des Nutzers). Spiegelt die offene Semantik von addToMine(): jeder + * authentifizierte Nutzer darf seine eigene Zuordnung lösen. Die Stammdaten + * bleiben dem Ersteller vorbehalten (siehe update()). + */ + public function removeFromMine(User $user, Meetup $meetup): bool + { + return true; + } + public function update(User $user, Meetup $meetup): bool { return $this->owns($user, $meetup); diff --git a/routes/api.php b/routes/api.php index a8b8a77..ee8a53a 100644 --- a/routes/api.php +++ b/routes/api.php @@ -78,6 +78,7 @@ Route::middleware('auth:sanctum') Route::post('meetup/{meetup}/logo', [MeetupController::class, 'uploadLogo'])->name('meetup.logo'); Route::get('my-meetups', [MeetupController::class, 'mine'])->name('meetup.mine'); Route::post('my-meetups/{meetup:slug}', [MeetupController::class, 'addToMine'])->name('meetup.mine.add'); + Route::delete('my-meetups/{meetup:slug}', [MeetupController::class, 'removeFromMine'])->name('meetup.mine.remove'); Route::get('my-meetups/{meetup}', [MeetupController::class, 'mineShow'])->name('meetup.mine.show'); Route::post('meetup-events', [MeetupEventController::class, 'store'])->name('meetup-events.store'); diff --git a/tests/Feature/Api/MeetupWriteApiTest.php b/tests/Feature/Api/MeetupWriteApiTest.php index 4d24b08..5463692 100644 --- a/tests/Feature/Api/MeetupWriteApiTest.php +++ b/tests/Feature/Api/MeetupWriteApiTest.php @@ -153,3 +153,61 @@ it('returns 404 for an unknown meetup slug', function () { $this->postJson('/api/my-meetups/does-not-exist-9999')->assertNotFound(); }); + +it('rejects a guest removing a meetup from mine', function () { + $meetup = Meetup::factory()->create(); + + $this->deleteJson('/api/my-meetups/'.$meetup->slug)->assertUnauthorized(); +}); + +it('lets an authenticated user remove a meetup from mine by slug', function () { + Sanctum::actingAs($user = User::factory()->create()); + $meetup = Meetup::factory()->create(); + $user->meetups()->attach($meetup, ['is_leader' => false]); + + $this->deleteJson('/api/my-meetups/'.$meetup->slug) + ->assertOk() + ->assertJsonPath('data.id', $meetup->id); + + // Nur die Pivot-Zuordnung wird gelöst — die Stammdaten bleiben erhalten. + $this->assertDatabaseMissing('meetup_user', [ + 'meetup_id' => $meetup->id, + 'user_id' => $user->id, + ]); + $this->assertDatabaseHas('meetups', ['id' => $meetup->id]); +}); + +it('is idempotent and returns 200 when the meetup is not mine', function () { + Sanctum::actingAs(User::factory()->create()); + $meetup = Meetup::factory()->create(); + + $this->deleteJson('/api/my-meetups/'.$meetup->slug) + ->assertOk() + ->assertJsonPath('data.id', $meetup->id); +}); + +it('only removes the acting users membership, not other members', function () { + $other = User::factory()->create(); + $meetup = Meetup::factory()->create(); + $meetup->users()->attach($other, ['is_leader' => false]); + + Sanctum::actingAs($user = User::factory()->create()); + $meetup->users()->attach($user, ['is_leader' => false]); + + $this->deleteJson('/api/my-meetups/'.$meetup->slug)->assertOk(); + + $this->assertDatabaseHas('meetup_user', [ + 'meetup_id' => $meetup->id, + 'user_id' => $other->id, + ]); + $this->assertDatabaseMissing('meetup_user', [ + 'meetup_id' => $meetup->id, + 'user_id' => $user->id, + ]); +}); + +it('returns 404 when removing an unknown meetup slug', function () { + Sanctum::actingAs(User::factory()->create()); + + $this->deleteJson('/api/my-meetups/does-not-exist-9999')->assertNotFound(); +});