false]); }); test('authenticated users can view their categories', function () { $user = User::factory()->create(); $categories = Category::factory()->count(3)->create(['user_id' => $user->id]); $response = $this->actingAs($user)->get(route('categories.index')); $response->assertSuccessful(); $response->assertInertia(fn ($page) => $page ->component('settings/categories') ->has('categories', 3) ); }); test('authenticated users can only view their own categories', function () { $user = User::factory()->create(); $otherUser = User::factory()->create(); Category::factory()->create(['user_id' => $user->id, 'name' => 'My Category']); Category::factory()->create(['user_id' => $otherUser->id, 'name' => 'Other Category']); $response = $this->actingAs($user)->get(route('categories.index')); $response->assertSuccessful(); $response->assertInertia(fn ($page) => $page ->component('settings/categories') ->has('categories', 1) ->where('categories.0.name', 'My Category') ); }); test('authenticated users can create a category', function () { $user = User::factory()->create(); $categoryData = [ 'name' => 'Shopping', 'icon' => 'ShoppingBag', 'color' => 'blue', 'type' => 'expense', 'cashflow_direction' => 'outflow', ]; $response = $this->actingAs($user)->post(route('categories.store'), $categoryData); $response->assertRedirect(route('categories.index')); $this->assertDatabaseHas('categories', [ 'user_id' => $user->id, 'name' => 'Shopping', 'icon' => 'ShoppingBag', 'color' => 'blue', 'type' => 'expense', 'cashflow_direction' => CategoryCashflowDirection::Hidden->value, ]); }); test('category name is required', function () { $user = User::factory()->create(); $response = $this->actingAs($user)->post(route('categories.store'), [ 'icon' => 'ShoppingBag', 'color' => 'blue', 'type' => 'expense', 'cashflow_direction' => 'hidden', ]); $response->assertSessionHasErrors(['name']); }); test('category names must be unique for each user when creating', function () { $user = User::factory()->create(); Category::factory()->create([ 'user_id' => $user->id, 'name' => 'Healthcare', ]); $response = $this->actingAs($user)->post(route('categories.store'), [ 'name' => 'Healthcare', 'icon' => 'Heart', 'color' => 'pink', 'type' => 'expense', 'cashflow_direction' => 'hidden', ]); $response->assertSessionHasErrors(['name']); expect($user->categories()->where('name', 'Healthcare')->count())->toBe(1); }); test('different users can create categories with the same name', function () { $user = User::factory()->create(); $otherUser = User::factory()->create(); Category::factory()->create([ 'user_id' => $otherUser->id, 'name' => 'Healthcare', ]); $response = $this->actingAs($user)->post(route('categories.store'), [ 'name' => 'Healthcare', 'icon' => 'Heart', 'color' => 'pink', 'type' => 'expense', 'cashflow_direction' => 'hidden', ]); $response->assertRedirect(route('categories.index')); $this->assertDatabaseHas('categories', [ 'user_id' => $user->id, 'name' => 'Healthcare', ]); }); 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' => 'Supermercados', ]); $category->delete(); $response = $this->actingAs($user)->post(route('categories.store'), [ 'name' => 'Supermercados', 'icon' => 'ShoppingCart', 'color' => 'green', 'type' => 'expense', 'cashflow_direction' => 'hidden', ]); $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 () { $user = User::factory()->create(); $response = $this->actingAs($user)->post(route('categories.store'), [ 'name' => 'Shopping', 'color' => 'blue', 'type' => 'expense', ]); $response->assertSessionHasErrors(['icon']); }); test('category color is required', function () { $user = User::factory()->create(); $response = $this->actingAs($user)->post(route('categories.store'), [ 'name' => 'Shopping', 'icon' => 'ShoppingBag', 'type' => 'expense', ]); $response->assertSessionHasErrors(['color']); }); test('category color must be valid', function () { $user = User::factory()->create(); $response = $this->actingAs($user)->post(route('categories.store'), [ 'name' => 'Shopping', 'icon' => 'ShoppingBag', 'color' => 'invalid-color', 'type' => 'expense', ]); $response->assertSessionHasErrors(['color']); }); test('category type is required', function () { $user = User::factory()->create(); $response = $this->actingAs($user)->post(route('categories.store'), [ 'name' => 'Shopping', 'icon' => 'ShoppingBag', 'color' => 'blue', ]); $response->assertSessionHasErrors(['type']); }); test('category type must be valid', function () { $user = User::factory()->create(); $response = $this->actingAs($user)->post(route('categories.store'), [ 'name' => 'Shopping', 'icon' => 'ShoppingBag', 'color' => 'blue', 'type' => 'invalid-type', ]); $response->assertSessionHasErrors(['type']); }); test('authenticated users can update their own category', function () { $user = User::factory()->create(); $category = Category::factory()->create(['user_id' => $user->id]); $updateData = [ 'name' => 'Updated Name', 'icon' => 'Home', 'color' => 'green', 'type' => 'transfer', 'cashflow_direction' => 'outflow', ]; $response = $this->actingAs($user)->patch( route('categories.update', $category), $updateData ); $response->assertRedirect(route('categories.index')); $this->assertDatabaseHas('categories', [ 'id' => $category->id, 'name' => 'Updated Name', 'icon' => 'Home', 'color' => 'green', 'type' => 'transfer', 'cashflow_direction' => 'outflow', ]); }); test('users can keep their category name when updating', function () { $user = User::factory()->create(); $category = Category::factory()->create([ 'user_id' => $user->id, 'name' => 'Healthcare', 'type' => 'expense', ]); $response = $this->actingAs($user)->patch( route('categories.update', $category), [ 'name' => 'Healthcare', 'icon' => 'Heart', 'color' => 'pink', 'type' => 'expense', 'cashflow_direction' => 'hidden', ] ); $response->assertRedirect(route('categories.index')); $this->assertDatabaseHas('categories', [ 'id' => $category->id, 'name' => 'Healthcare', 'color' => 'pink', ]); }); test('category names must be unique for each user when updating', function () { $user = User::factory()->create(); $category = Category::factory()->create([ 'user_id' => $user->id, 'name' => 'Old Name', ]); Category::factory()->create([ 'user_id' => $user->id, 'name' => 'Healthcare', ]); $response = $this->actingAs($user)->patch( route('categories.update', $category), [ 'name' => 'Healthcare', 'icon' => 'Heart', 'color' => 'pink', 'type' => 'expense', 'cashflow_direction' => 'hidden', ] ); $response->assertSessionHasErrors(['name']); $this->assertDatabaseHas('categories', [ 'id' => $category->id, 'name' => 'Old Name', ]); }); test('transfer categories can store a cashflow direction', function () { $user = User::factory()->create(); $response = $this->actingAs($user)->post(route('categories.store'), [ 'name' => 'Investment', 'icon' => 'TrendingUp', 'color' => 'green', 'type' => 'transfer', 'cashflow_direction' => 'outflow', ]); $response->assertRedirect(route('categories.index')); $this->assertDatabaseHas('categories', [ 'user_id' => $user->id, 'name' => 'Investment', 'type' => 'transfer', 'cashflow_direction' => 'outflow', ]); }); test('non-transfer categories are forced to hidden cashflow direction', function () { $user = User::factory()->create(); $response = $this->actingAs($user)->post(route('categories.store'), [ 'name' => 'Salary', 'icon' => 'Coins', 'color' => 'green', 'type' => 'income', 'cashflow_direction' => 'outflow', ]); $response->assertRedirect(route('categories.index')); $this->assertDatabaseHas('categories', [ 'user_id' => $user->id, 'name' => 'Salary', 'type' => 'income', 'cashflow_direction' => CategoryCashflowDirection::Hidden->value, ]); }); test('users can create savings and investment categories', function (string $type) { $user = User::factory()->create(); $response = $this->actingAs($user)->post(route('categories.store'), [ 'name' => ucfirst($type), 'icon' => $type === CategoryType::Savings->value ? 'PiggyBank' : 'TrendingUp', 'color' => 'lime', 'type' => $type, 'cashflow_direction' => 'outflow', ]); $response->assertRedirect(route('categories.index')); $this->assertDatabaseHas('categories', [ 'user_id' => $user->id, 'name' => ucfirst($type), 'type' => $type, 'cashflow_direction' => CategoryCashflowDirection::Outflow->value, ]); })->with([ CategoryType::Savings->value, CategoryType::Investment->value, ]); test('migration updates existing default saving and investment categories', function () { $user = User::factory()->create(); $investments = Category::factory()->create([ 'user_id' => $user->id, 'name' => 'Investments', 'icon' => 'LineChart', 'color' => 'lime', 'type' => CategoryType::Transfer, 'cashflow_direction' => CategoryCashflowDirection::Outflow, ]); $savings = Category::factory()->create([ 'user_id' => $user->id, 'name' => 'Savings', 'icon' => 'PiggyBank', 'color' => 'lime', 'type' => CategoryType::Transfer, 'cashflow_direction' => CategoryCashflowDirection::Outflow, ]); $legacyExpenseInvestment = Category::factory()->create([ 'user_id' => $user->id, 'name' => 'Other investments', 'icon' => 'TrendingUp', 'color' => 'lime', 'type' => CategoryType::Expense, 'cashflow_direction' => CategoryCashflowDirection::Hidden, ]); $legacySpanishSavings = Category::factory()->create([ 'user_id' => $user->id, 'name' => 'Ahorros', 'icon' => 'PiggyBank', 'color' => 'lime', 'type' => CategoryType::Expense, 'cashflow_direction' => CategoryCashflowDirection::Hidden, ]); $customTransfer = Category::factory()->create([ 'user_id' => $user->id, 'name' => 'Investment transfer', 'icon' => 'LineChart', 'color' => 'lime', 'type' => CategoryType::Transfer, 'cashflow_direction' => CategoryCashflowDirection::Outflow, ]); $migration = require database_path('migrations/2026_05_25_115100_update_default_saving_and_investment_category_types.php'); $migration->up(); expect($investments->refresh()) ->type->toBe(CategoryType::Investment) ->cashflow_direction->toBe(CategoryCashflowDirection::Hidden); expect($savings->refresh()) ->type->toBe(CategoryType::Savings) ->cashflow_direction->toBe(CategoryCashflowDirection::Hidden); expect($legacyExpenseInvestment->refresh()) ->type->toBe(CategoryType::Investment) ->cashflow_direction->toBe(CategoryCashflowDirection::Hidden); expect($legacySpanishSavings->refresh()) ->type->toBe(CategoryType::Savings) ->cashflow_direction->toBe(CategoryCashflowDirection::Hidden); expect($customTransfer->refresh()) ->type->toBe(CategoryType::Transfer) ->cashflow_direction->toBe(CategoryCashflowDirection::Outflow); }); test('migration sets saving and investment categories to cashflow outflow', function () { $user = User::factory()->create(); $savings = Category::factory()->create([ 'user_id' => $user->id, 'type' => CategoryType::Savings, 'cashflow_direction' => CategoryCashflowDirection::Hidden, ]); $investment = Category::factory()->create([ 'user_id' => $user->id, 'type' => CategoryType::Investment, 'cashflow_direction' => CategoryCashflowDirection::Hidden, ]); $transfer = Category::factory()->create([ 'user_id' => $user->id, 'type' => CategoryType::Transfer, 'cashflow_direction' => CategoryCashflowDirection::Inflow, ]); $expense = Category::factory()->create([ 'user_id' => $user->id, 'type' => CategoryType::Expense, 'cashflow_direction' => CategoryCashflowDirection::Hidden, ]); $migration = require database_path('migrations/2026_05_29_085835_update_saving_and_investment_category_cashflow_direction.php'); $migration->up(); expect($savings->refresh()->cashflow_direction)->toBe(CategoryCashflowDirection::Outflow); expect($investment->refresh()->cashflow_direction)->toBe(CategoryCashflowDirection::Outflow); expect($transfer->refresh()->cashflow_direction)->toBe(CategoryCashflowDirection::Inflow); expect($expense->refresh()->cashflow_direction)->toBe(CategoryCashflowDirection::Hidden); $migration->down(); expect($savings->refresh()->cashflow_direction)->toBe(CategoryCashflowDirection::Hidden); expect($investment->refresh()->cashflow_direction)->toBe(CategoryCashflowDirection::Hidden); }); test('users cannot update categories they do not own', function () { $user = User::factory()->create(); $otherUser = User::factory()->create(); $category = Category::factory()->create(['user_id' => $otherUser->id]); $response = $this->actingAs($user)->patch( route('categories.update', $category), [ 'name' => 'Updated Name', 'icon' => 'Home', 'color' => 'green', 'type' => 'expense', ] ); $response->assertForbidden(); }); test('authenticated users can delete their own category', function () { $user = User::factory()->create(); $category = Category::factory()->create(['user_id' => $user->id]); $response = $this->actingAs($user)->delete(route('categories.destroy', $category)); $response->assertRedirect(route('categories.index')); $this->assertSoftDeleted('categories', [ 'id' => $category->id, ]); }); test('users cannot delete categories they do not own', function () { $user = User::factory()->create(); $otherUser = User::factory()->create(); $category = Category::factory()->create(['user_id' => $otherUser->id]); $response = $this->actingAs($user)->delete(route('categories.destroy', $category)); $response->assertForbidden(); $this->assertDatabaseHas('categories', [ 'id' => $category->id, 'deleted_at' => null, ]); }); test('guests cannot access category management', function () { $response = $this->get(route('categories.index')); $response->assertRedirect(route('register')); $response = $this->post(route('categories.store'), []); $response->assertRedirect(route('register')); }); test('default categories are created when user registers', function () { $user = User::factory()->create(); $service = new CreateDefaultCategories; $service->handle($user); expect($user->categories()->count())->toBe(64); $categoryNames = $user->categories->pluck('name')->toArray(); expect($categoryNames)->toContain('Food', 'Transportation', 'Salary', 'Insurance'); $user->refresh(); expect($user->categories()->firstWhere('name', 'Investments')) ->type->toBe(CategoryType::Investment) ->cashflow_direction->toBe(CategoryCashflowDirection::Outflow); expect($user->categories()->firstWhere('name', 'Savings')) ->type->toBe(CategoryType::Savings) ->cashflow_direction->toBe(CategoryCashflowDirection::Outflow); expect($user->categories()->firstWhere('name', 'Other investments')) ->type->toBe(CategoryType::Investment) ->cashflow_direction->toBe(CategoryCashflowDirection::Outflow); expect($user->categories()->firstWhere('name', 'From account of relatives')) ->type->toBe(CategoryType::Transfer) ->cashflow_direction->toBe(CategoryCashflowDirection::Inflow); }); test('default categories nest children under their configured parent', function () { $user = User::factory()->create(); (new CreateDefaultCategories)->handle($user); $categories = $user->categories()->get()->keyBy('name'); expect($categories->get('Food')->parent_id)->toBeNull(); expect($categories->get('Groceries')->parent_id)->toBe($categories->get('Food')->id); expect($categories->get('Electricity')->parent_id)->toBe($categories->get('Utility services')->id); expect($categories->get('Fuel')->parent_id)->toBe($categories->get('Transportation')->id); expect($categories->get('Other investments')->parent_id)->toBe($categories->get('Investments')->id); expect($categories->get('Salary')->parent_id)->toBeNull(); }); test('default child categories attach to an already existing parent', function () { $user = User::factory()->create(); $food = Category::factory()->create([ 'user_id' => $user->id, 'name' => 'Food', 'type' => CategoryType::Expense, ]); (new CreateDefaultCategories)->handle($user); expect($user->categories()->firstWhere('name', 'Groceries')->parent_id)->toBe($food->id); }); test('default categories are not created twice for the same user', function () { $user = User::factory()->create(); $service = new CreateDefaultCategories; $service->handle($user); expect($user->categories()->count())->toBe(64); $service->handle($user); expect($user->categories()->count())->toBe(64); }); test('default categories are created without repeated category lookups', function () { $user = User::factory()->create(); $service = new CreateDefaultCategories; $categorySelects = 0; DB::listen(function ($query) use (&$categorySelects) { if (str_starts_with($query->sql, 'select `id`, `name` from `categories`')) { $categorySelects++; } }); $service->handle($user); expect($user->categories()->count())->toBe(64) ->and($categorySelects)->toBe(1); }); test('duplicate category name database errors become validation errors', function () { $user = User::factory()->create(); $controller = new CategoryController; $user->categories()->create([ 'name' => 'Healthcare', 'icon' => 'Heart', 'color' => 'pink', 'type' => 'expense', ]); $request = Mockery::mock(StoreCategoryRequest::class); $request->shouldReceive('validated')->once()->andReturn([ 'name' => 'Healthcare', 'icon' => 'Heart', 'color' => 'pink', 'type' => 'expense', 'cashflow_direction' => 'hidden', ]); $this->actingAs($user); $thrown = null; try { $controller->store($request); } catch (ValidationException $exception) { $thrown = $exception; } expect($thrown)->toBeInstanceOf(ValidationException::class); expect($thrown->errors())->toHaveKey('name'); }); test('category names are unique per user', function () { $user = User::factory()->create(); $category = $user->categories()->create([ 'name' => 'Test Category', 'icon' => 'Tag', 'color' => 'red', 'type' => 'expense', ]); expect($category)->toBeInstanceOf(Category::class); $this->expectException(UniqueConstraintViolationException::class); $user->categories()->create([ 'name' => 'Test Category', 'icon' => 'Tag', 'color' => 'blue', 'type' => 'expense', ]); }); test('categories index returns the standard category field set without internal columns', function () { $user = User::factory()->create(); Category::factory()->create(['user_id' => $user->id]); $response = $this->actingAs($user)->withoutVite()->get(route('categories.index')); $props = $response->viewData('page')['props']; expect(array_keys($props['categories'][0])) ->toEqualCanonicalizing(['id', 'name', 'icon', 'color', 'type', 'cashflow_direction', 'parent_id']); });