Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/Http/Controllers/Api/EventController.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ public function store(Request $request): JsonResponse
$validator = Validator::make($request->all(), [
'name' => [
'required', 'string', 'max:255',
Rule::unique('events', 'name')->where('user_id', $request->user()->id),
Rule::unique('events', 'name')->where('user_id', $request->user()->id)->withoutTrashed(),
],
'description' => 'nullable|string|max:1000',
'schema' => ['nullable', 'array', new ValidEventSchema()],
Expand Down Expand Up @@ -95,7 +95,7 @@ public function update(Request $request, Event $event): JsonResponse
$validator = Validator::make($request->all(), [
'name' => [
'string', 'max:255',
Rule::unique('events', 'name')->where('user_id', $request->user()->id)->ignore($event->id),
Rule::unique('events', 'name')->where('user_id', $request->user()->id)->ignore($event->id)->withoutTrashed(),
],
'description' => 'nullable|string|max:1000',
'schema' => ['nullable', 'array', new ValidEventSchema()],
Expand Down
4 changes: 2 additions & 2 deletions app/Http/Controllers/EventController.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ public function store(Request $request): RedirectResponse
$validated = $request->validate([
'name' => [
'required', 'string', 'max:255',
Rule::unique('events', 'name')->where('user_id', $request->user()->id),
Rule::unique('events', 'name')->where('user_id', $request->user()->id)->withoutTrashed(),
],
'event_type' => 'nullable|string|max:255',
'description' => 'nullable|string|max:1000',
Expand All @@ -40,7 +40,7 @@ public function update(Request $request, Event $event): RedirectResponse
$validated = $request->validate([
'name' => [
'required', 'string', 'max:255',
Rule::unique('events', 'name')->where('user_id', $request->user()->id)->ignore($event->id),
Rule::unique('events', 'name')->where('user_id', $request->user()->id)->ignore($event->id)->withoutTrashed(),
],
'event_type' => 'nullable|string|max:255',
'description' => 'nullable|string|max:1000',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;

return new class () extends Migration {
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('events', function (Blueprint $table) {
$table->dropUnique(['user_id', 'name']);
});

// A plain unique index on (user_id, name) also matches soft-deleted
// rows, permanently blocking reuse of a deleted event's name since
// there is no restore path in the app. Scope the constraint to
// non-deleted rows only via a partial index; this syntax is
// supported identically by SQLite (tests) and PostgreSQL
// (production), the two drivers this app runs on.
DB::statement(
'CREATE UNIQUE INDEX events_user_id_name_unique ON events (user_id, name) WHERE deleted_at IS NULL'
);
}

/**
* Reverse the migrations.
*/
public function down(): void
{
DB::statement('DROP INDEX events_user_id_name_unique');

Schema::table('events', function (Blueprint $table) {
$table->unique(['user_id', 'name']);
});
}
};
91 changes: 91 additions & 0 deletions tests/Feature/SoftDeletedEventNameReuseTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
<?php

namespace Tests\Feature;

use App\Models\Event;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class SoftDeletedEventNameReuseTest extends TestCase
{
use RefreshDatabase;

public function test_user_can_reuse_the_name_of_a_soft_deleted_event_via_web(): void
{
$user = User::factory()->withPersonalTeam()->create();
$original = Event::factory()->for($user)->create(['name' => 'order.created']);
$original->delete();

$this->assertSoftDeleted($original);

$this->actingAs($user)
->post('/events', ['name' => 'order.created'])
->assertRedirect(route('events'));

$this->assertDatabaseHas('events', [
'user_id' => $user->id,
'name' => 'order.created',
'deleted_at' => null,
]);
}

public function test_user_can_rename_an_event_to_the_name_of_a_soft_deleted_event_via_web(): void
{
$user = User::factory()->withPersonalTeam()->create();
$deleted = Event::factory()->for($user)->create(['name' => 'order.created']);
$deleted->delete();
$active = Event::factory()->for($user)->create(['name' => 'order.updated']);

$this->actingAs($user)
->put("/events/{$active->id}", ['name' => 'order.created'])
->assertRedirect(route('events'));

$this->assertDatabaseHas('events', [
'id' => $active->id,
'name' => 'order.created',
]);
}

public function test_user_still_cannot_reuse_the_name_of_an_active_event_via_web(): void
{
$user = User::factory()->withPersonalTeam()->create();
Event::factory()->for($user)->create(['name' => 'order.created']);

$this->actingAs($user)
->post('/events', ['name' => 'order.created'])
->assertSessionHasErrors('name');

$this->assertDatabaseCount('events', 1);
}

public function test_user_can_reuse_the_name_of_a_soft_deleted_event_via_api(): void
{
$user = User::factory()->withPersonalTeam()->create();
$original = Event::factory()->for($user)->create(['name' => 'order.created']);
$original->delete();

$this->actingAs($user)
->postJson('/api/v1/events', ['name' => 'order.created'])
->assertStatus(201);

$this->assertDatabaseHas('events', [
'user_id' => $user->id,
'name' => 'order.created',
'deleted_at' => null,
]);
}

public function test_user_still_cannot_reuse_the_name_of_an_active_event_via_api(): void
{
$user = User::factory()->withPersonalTeam()->create();
Event::factory()->for($user)->create(['name' => 'order.created']);

$this->actingAs($user)
->postJson('/api/v1/events', ['name' => 'order.created'])
->assertStatus(422)
->assertJsonValidationErrors('name');

$this->assertDatabaseCount('events', 1);
}
}
Loading