fix(categories): allow recreate after delete (#444)

## Summary
- allow users to recreate category names after soft delete
- update category unique validation to ignore trashed rows
- add active-row unique index for category names

## Tests
- php artisan test --compact tests/Feature/Settings/CategoryTest.php
This commit is contained in:
Víctor Falcón 2026-05-28 09:46:02 +02:00 committed by GitHub
parent 4f46ae3e2d
commit 2fa822e6d9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 52 additions and 8 deletions

View File

@ -76,7 +76,8 @@ class CategoryController extends Controller
private function throwDuplicateCategoryNameValidationException(UniqueConstraintViolationException $exception): never
{
if (! str_contains($exception->getMessage(), 'categories_user_id_name_unique')) {
if (! str_contains($exception->getMessage(), 'categories_user_id_name_unique')
&& ! str_contains($exception->getMessage(), 'categories_user_id_name_active_unique')) {
throw $exception;
}

View File

@ -47,7 +47,8 @@ class StoreCategoryRequest extends FormRequest
'string',
'max:255',
Rule::unique('categories', 'name')
->where('user_id', auth()->id()),
->where('user_id', auth()->id())
->withoutTrashed(),
],
'icon' => ['required', 'string'],
'color' => [

View File

@ -48,6 +48,7 @@ class UpdateCategoryRequest extends FormRequest
'max:255',
Rule::unique('categories', 'name')
->where('user_id', auth()->id())
->withoutTrashed()
->ignore($this->route('category')),
],
'icon' => ['required', 'string'],

View File

@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('categories', function (Blueprint $table) {
$table->dropForeign(['user_id']);
$table->dropUnique(['user_id', 'name']);
$table->boolean('active_unique_marker')
->nullable()
->virtualAs('if(`deleted_at` is null, 1, null)');
$table->unique(['user_id', 'name', 'active_unique_marker'], 'categories_user_id_name_active_unique');
$table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('categories', function (Blueprint $table) {
$table->dropForeign(['user_id']);
$table->dropUnique('categories_user_id_name_active_unique');
$table->dropColumn('active_unique_marker');
$table->unique(['user_id', 'name']);
$table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete();
});
}
};

View File

@ -129,24 +129,27 @@ test('different users can create categories with the same name', function () {
]);
});
test('category names from deleted categories remain reserved', function () {
test('users can recreate a category with the same name after deleting it', function () {
$user = User::factory()->create();
$category = Category::factory()->create([
'user_id' => $user->id,
'name' => 'Healthcare',
'name' => 'Supermercados',
]);
$category->delete();
$response = $this->actingAs($user)->post(route('categories.store'), [
'name' => 'Healthcare',
'icon' => 'Heart',
'color' => 'pink',
'name' => 'Supermercados',
'icon' => 'ShoppingCart',
'color' => 'green',
'type' => 'expense',
'cashflow_direction' => 'hidden',
]);
$response->assertSessionHasErrors(['name']);
$response->assertRedirect(route('categories.index'));
expect($user->categories()->where('name', 'Supermercados')->count())->toBe(1)
->and($user->categories()->withTrashed()->where('name', 'Supermercados')->count())->toBe(2);
});
test('category icon is required', function () {