whisper-money/tests/Feature/Settings/CategoryTest.php

666 lines
21 KiB
PHP

<?php
use App\Actions\CreateDefaultCategories;
use App\Enums\CategoryCashflowDirection;
use App\Enums\CategoryType;
use App\Http\Controllers\Settings\CategoryController;
use App\Http\Requests\Settings\StoreCategoryRequest;
use App\Models\Category;
use App\Models\User;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
beforeEach(function () {
config(['landing.hide_auth_buttons' => 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',
]);
});