Add category type field support (#2)

* Add category type field support

- Remove default value from type column in migration
- Add type validation to StoreCategoryRequest and UpdateCategoryRequest
- Include type field in CategoryController index response
- Assign proper types to all default categories (income/expense/transfer)
- Update CategoryFactory to include type field
- Add type field to TypeScript Category interface
- Add type select field to create and edit category dialogs
- Update all category tests to include type field
- Add new tests for type field validation

All tests passing (27 category-related tests)

* Add CategoryColor enum and update validation rules

* Remove unused AccountType enum class

* Add category type column to settings page

* Remove obsolete import screenshots, add new category edit screenshot

* feat(ui): add transfer type description alerts

* feat(tests): Add .gitignore and update CategoriesTest.php for Browser tests

* fix: update transfer category UI and test

* chore: update .gitignore to include Screenshots directory

* Change category type from income to expense
This commit is contained in:
Víctor Falcón 2025-12-01 20:19:47 +01:00 committed by GitHub
parent 050963d3f6
commit baa77a248b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 338 additions and 56 deletions

View File

@ -1,12 +0,0 @@
<?php
namespace App\Enums;
enum AccountType: string
{
case Checking = 'checking';
case CreditCard = 'credit_card';
case Loan = 'loan';
case Savings = 'savings';
case Others = 'others';
}

View File

@ -24,7 +24,7 @@ class CreateDefaultCategories
/**
* Get the default categories configuration.
*
* @return array<int, array{name: string, icon: string, color: string}>
* @return array<int, array{name: string, icon: string, color: string, type: string}>
*/
public static function getDefaultCategories(): array
{
@ -33,316 +33,379 @@ class CreateDefaultCategories
'name' => 'Food',
'icon' => 'Utensils',
'color' => 'red',
'type' => 'expense',
],
[
'name' => 'Cafes, restaurants, bars',
'icon' => 'Wine',
'color' => 'red',
'type' => 'expense',
],
[
'name' => 'Groceries',
'icon' => 'ShoppingBasket',
'color' => 'red',
'type' => 'expense',
],
[
'name' => 'Tobacco and alcohol',
'icon' => 'Cigarette',
'color' => 'red',
'type' => 'expense',
],
[
'name' => 'Other groceries',
'icon' => 'ShoppingBasket',
'color' => 'red',
'type' => 'expense',
],
[
'name' => 'Food delivery',
'icon' => 'Pizza',
'color' => 'red',
'type' => 'expense',
],
[
'name' => 'Utility services',
'icon' => 'Home',
'color' => 'orange',
'type' => 'expense',
],
[
'name' => 'Electricity',
'icon' => 'Bolt',
'color' => 'orange',
'type' => 'expense',
],
[
'name' => 'Natural gas',
'icon' => 'Flame',
'color' => 'orange',
'type' => 'expense',
],
[
'name' => 'Rent and maintanence',
'icon' => 'Wrench',
'color' => 'orange',
'type' => 'expense',
],
[
'name' => 'Telephone, internet, TV, computer',
'icon' => 'Wifi',
'color' => 'orange',
'type' => 'expense',
],
[
'name' => 'Water',
'icon' => 'Droplets',
'color' => 'orange',
'type' => 'expense',
],
[
'name' => 'Other utility expenses',
'icon' => 'Receipt',
'color' => 'orange',
'type' => 'expense',
],
[
'name' => 'Household goods',
'icon' => 'Home',
'color' => 'orange',
'type' => 'expense',
],
[
'name' => 'Transportation',
'icon' => 'Bus',
'color' => 'amber',
'type' => 'expense',
],
[
'name' => 'Parking',
'icon' => 'ParkingMeter',
'color' => 'amber',
'type' => 'expense',
],
[
'name' => 'Fuel',
'icon' => 'Fuel',
'color' => 'amber',
'type' => 'expense',
],
[
'name' => 'Transportation expenses',
'icon' => 'Ticket',
'color' => 'amber',
'type' => 'expense',
],
[
'name' => 'Vehicle purchase, maintenance',
'icon' => 'Car',
'color' => 'amber',
'type' => 'expense',
],
[
'name' => 'Clothing and shoes',
'icon' => 'Shirt',
'color' => 'pink',
'type' => 'expense',
],
[
'name' => 'Leisure activities, traveling',
'icon' => 'Plane',
'color' => 'violet',
'type' => 'expense',
],
[
'name' => 'Gifts',
'icon' => 'Gift',
'color' => 'violet',
'type' => 'expense',
],
[
'name' => 'Books, newspapers, magazines',
'icon' => 'BookOpen',
'color' => 'violet',
'type' => 'expense',
],
[
'name' => 'Accommodation, travel expenses',
'icon' => 'Hotel',
'color' => 'violet',
'type' => 'expense',
],
[
'name' => 'Sport and sports goods',
'icon' => 'Dumbbell',
'color' => 'violet',
'type' => 'expense',
],
[
'name' => 'Theatre, music, cinema',
'icon' => 'Clapperboard',
'color' => 'violet',
'type' => 'expense',
],
[
'name' => 'Hobbies and other leisure time activites',
'icon' => 'Puzzle',
'color' => 'violet',
'type' => 'expense',
],
[
'name' => 'Education, health and beauty',
'icon' => 'GraduationCap',
'color' => 'rose',
'type' => 'expense',
],
[
'name' => 'Education and courses',
'icon' => 'GraduationCap',
'color' => 'rose',
'type' => 'expense',
],
[
'name' => 'Beauty, cosmetics',
'icon' => 'Sparkles',
'color' => 'rose',
'type' => 'expense',
],
[
'name' => 'Health and pharmaceuticals',
'icon' => 'HeartPulse',
'color' => 'rose',
'type' => 'expense',
],
[
'name' => 'Online transactions',
'icon' => 'Globe',
'color' => 'fuchsia',
'type' => 'expense',
],
[
'name' => 'Online services',
'icon' => 'Server',
'color' => 'fuchsia',
'type' => 'expense',
],
[
'name' => 'Insurance',
'icon' => 'ShieldCheck',
'color' => 'yellow',
'type' => 'expense',
],
[
'name' => 'Investments',
'icon' => 'LineChart',
'color' => 'lime',
'type' => 'expense',
],
[
'name' => 'Savings',
'icon' => 'PiggyBank',
'color' => 'lime',
'type' => 'expense',
],
[
'name' => 'Other investments',
'icon' => 'TrendingUp',
'color' => 'lime',
'type' => 'expense',
],
[
'name' => 'Financial services and commission',
'icon' => 'Landmark',
'color' => 'slate',
'type' => 'expense',
],
[
'name' => 'Fines',
'icon' => 'Gavel',
'color' => 'slate',
'type' => 'expense',
],
[
'name' => 'Mortgage',
'icon' => 'Building2',
'color' => 'slate',
'type' => 'expense',
],
[
'name' => 'Credit card repayment',
'icon' => 'CreditCard',
'color' => 'slate',
'type' => 'expense',
],
[
'name' => 'Cash withdrawal',
'icon' => 'Banknote',
'color' => 'neutral',
'type' => 'expense',
],
[
'name' => 'Gambling',
'icon' => 'Dice5',
'color' => 'purple',
'type' => 'expense',
],
[
'name' => 'Lottery',
'icon' => 'TicketPercent',
'color' => 'purple',
'type' => 'expense',
],
[
'name' => 'Taxes and government fees',
'icon' => 'FileText',
'color' => 'stone',
'type' => 'expense',
],
[
'name' => 'Invoices',
'icon' => 'FileInvoice',
'color' => 'stone',
'type' => 'expense',
],
[
'name' => 'Personal transfers',
'icon' => 'ArrowLeftRight',
'color' => 'cyan',
'type' => 'transfer',
],
[
'name' => 'Other personal transfers',
'icon' => 'ArrowLeftRight',
'color' => 'cyan',
'type' => 'transfer',
],
[
'name' => 'Administrative violations',
'icon' => 'BadgeAlert',
'color' => 'stone',
'type' => 'expense',
],
[
'name' => 'Other transfers',
'icon' => 'Split',
'color' => 'stone',
'type' => 'transfer',
],
[
'name' => 'Other payments',
'icon' => 'Wallet',
'color' => 'stone',
'type' => 'expense',
],
[
'name' => 'Salary',
'icon' => 'Coins',
'color' => 'green',
'type' => 'income',
],
[
'name' => 'Regular income',
'icon' => 'Coins',
'color' => 'green',
'type' => 'income',
],
[
'name' => 'Work on demand',
'icon' => 'Briefcase',
'color' => 'green',
'type' => 'income',
],
[
'name' => 'Income from rent',
'icon' => 'Building',
'color' => 'green',
'type' => 'income',
],
[
'name' => 'Unemployment benefit',
'icon' => 'HandCoins',
'color' => 'green',
'type' => 'income',
],
[
'name' => 'Tax return',
'icon' => 'RotateCcw',
'color' => 'green',
'type' => 'income',
],
[
'name' => 'Return debit',
'icon' => 'Undo2',
'color' => 'green',
'type' => 'income',
],
[
'name' => 'Own account',
'icon' => 'ArrowRightLeft',
'color' => 'blue',
'type' => 'transfer',
],
[
'name' => 'From account of relatives',
'icon' => 'Users',
'color' => 'blue',
'type' => 'transfer',
],
[
'name' => 'Returned payments',
'icon' => 'RotateCw',
'color' => 'green',
'type' => 'income',
],
[
'name' => 'Credit cards',
'icon' => 'CreditCard',
'color' => 'green',
'type' => 'expense',
],
[
'name' => 'Other incoming payments',
'icon' => 'DollarSign',
'color' => 'green',
'type' => 'income',
],
];
}

View File

@ -130,6 +130,7 @@ class SetupMainUser extends Command
'name' => $categoryData['name'],
'icon' => $categoryData['icon'],
'color' => $categoryData['color'],
'type' => $categoryData['type'] ?? 'expense',
'created_at' => $categoryData['created_at'] ?? now(),
'updated_at' => $categoryData['updated_at'] ?? now(),
]);

View File

@ -0,0 +1,27 @@
<?php
namespace App\Enums;
enum CategoryColor: string
{
case Amber = 'amber';
case Blue = 'blue';
case Cyan = 'cyan';
case Emerald = 'emerald';
case Fuchsia = 'fuchsia';
case Gray = 'gray';
case Green = 'green';
case Indigo = 'indigo';
case Lime = 'lime';
case Neutral = 'neutral';
case Orange = 'orange';
case Pink = 'pink';
case Purple = 'purple';
case Red = 'red';
case Rose = 'rose';
case Slate = 'slate';
case Stone = 'stone';
case Teal = 'teal';
case Violet = 'violet';
case Yellow = 'yellow';
}

View File

@ -23,7 +23,7 @@ class CategoryController extends Controller
$categories = auth()->user()
->categories()
->orderBy('name')
->get(['id', 'name', 'icon', 'color']);
->get(['id', 'name', 'icon', 'color', 'type']);
return Inertia::render('settings/categories', [
'categories' => $categories,

View File

@ -2,6 +2,8 @@
namespace App\Http\Requests\Settings;
use App\Enums\CategoryColor;
use App\Enums\CategoryType;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
@ -29,22 +31,12 @@ class StoreCategoryRequest extends FormRequest
'color' => [
'required',
'string',
Rule::in([
'amber',
'blue',
'cyan',
'emerald',
'gray',
'green',
'indigo',
'orange',
'pink',
'purple',
'red',
'slate',
'teal',
'yellow',
]),
Rule::enum(CategoryColor::class),
],
'type' => [
'required',
'string',
Rule::enum(CategoryType::class),
],
];
}

View File

@ -2,6 +2,8 @@
namespace App\Http\Requests\Settings;
use App\Enums\CategoryColor;
use App\Enums\CategoryType;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
@ -29,22 +31,12 @@ class UpdateCategoryRequest extends FormRequest
'color' => [
'required',
'string',
Rule::in([
'amber',
'blue',
'cyan',
'emerald',
'gray',
'green',
'indigo',
'orange',
'pink',
'purple',
'red',
'slate',
'teal',
'yellow',
]),
Rule::enum(CategoryColor::class),
],
'type' => [
'required',
'string',
Rule::enum(CategoryType::class),
],
];
}

View File

@ -2,6 +2,7 @@
namespace Database\Factories;
use App\Enums\CategoryType;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
@ -21,6 +22,7 @@ class CategoryFactory extends Factory
'name' => fake()->unique()->randomElement(['Food', 'Transport', 'Entertainment', 'Shopping', 'Healthcare', 'Utilities', 'Travel', 'Education', 'Bills', 'Investments', 'Savings', 'Insurance', 'Gifts', 'Personal Care', 'Sports', 'Hobbies', 'Subscriptions', 'Dining', 'Groceries', 'Clothing']),
'icon' => fake()->randomElement(['AlertCircle', 'AlertTriangle', 'ArrowDownCircle', 'ArrowLeftRight', 'ArrowUpCircle', 'Baby', 'Banknote', 'Briefcase', 'Building', 'Building2', 'Car', 'Clock', 'CreditCard', 'Dices', 'FileText', 'Gift', 'GraduationCap', 'HandHeart', 'Heart', 'HelpCircle', 'Home', 'Landmark', 'Mail', 'PiggyBank', 'Plane', 'Receipt', 'ReceiptText', 'Repeat', 'RotateCcw', 'Scale', 'Shield', 'ShieldCheck', 'ShoppingBag', 'TrendingUp', 'Undo2', 'Users', 'Users2', 'Utensils', 'Wallet']),
'color' => fake()->randomElement(['amber', 'blue', 'cyan', 'emerald', 'gray', 'green', 'indigo', 'orange', 'pink', 'purple', 'red', 'slate', 'teal', 'yellow']),
'type' => fake()->randomElement(CategoryType::cases()),
'user_id' => User::factory(),
];
}

View File

@ -12,7 +12,7 @@ return new class extends Migration
public function up(): void
{
Schema::table('categories', function (Blueprint $table) {
$table->string('type')->default('expense')->after('color');
$table->string('type')->after('color');
});
}

View File

@ -1,4 +1,5 @@
import { store } from '@/actions/App/Http/Controllers/Settings/CategoryController';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
@ -22,10 +23,12 @@ import { categorySyncService } from '@/services/category-sync';
import {
CATEGORY_COLORS,
CATEGORY_ICONS,
CATEGORY_TYPES,
getCategoryColorClasses,
} from '@/types/category';
import { Form } from '@inertiajs/react';
import * as Icons from 'lucide-react';
import { Info } from 'lucide-react';
import { useState } from 'react';
export function CreateCategoryDialog({
@ -34,6 +37,7 @@ export function CreateCategoryDialog({
onSuccess?: () => void;
}) {
const [open, setOpen] = useState(false);
const [selectedType, setSelectedType] = useState<string>('');
return (
<Dialog open={open} onOpenChange={setOpen}>
@ -139,6 +143,44 @@ export function CreateCategoryDialog({
)}
</div>
<div className="space-y-2">
<Label htmlFor="type">Type</Label>
<Select
name="type"
required
onValueChange={setSelectedType}
>
<SelectTrigger>
<SelectValue placeholder="Select a type" />
</SelectTrigger>
<SelectContent>
{CATEGORY_TYPES.map((type) => (
<SelectItem key={type} value={type}>
{type.charAt(0).toUpperCase() +
type.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.type && (
<p className="text-sm text-red-500">
{errors.type}
</p>
)}
{selectedType === 'transfer' && (
<Alert>
<Info className="h-4 w-4 opacity-50" />
<AlertDescription className="text-sm">
Transactions in this category will
not be counted in top expenses or
income. Transfer categories are
mainly used for transactions between
accounts.
</AlertDescription>
</Alert>
)}
</div>
<div className="flex justify-end gap-2">
<Button
type="button"

View File

@ -1,4 +1,5 @@
import { update } from '@/actions/App/Http/Controllers/Settings/CategoryController';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
@ -21,11 +22,14 @@ import { categorySyncService } from '@/services/category-sync';
import {
CATEGORY_COLORS,
CATEGORY_ICONS,
CATEGORY_TYPES,
getCategoryColorClasses,
type Category,
} from '@/types/category';
import { Form } from '@inertiajs/react';
import * as Icons from 'lucide-react';
import { Info } from 'lucide-react';
import { useState } from 'react';
interface EditCategoryDialogProps {
category: Category;
@ -40,6 +44,8 @@ export function EditCategoryDialog({
onOpenChange,
onSuccess,
}: EditCategoryDialogProps) {
const [selectedType, setSelectedType] = useState<string>(category.type);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
@ -150,6 +156,45 @@ export function EditCategoryDialog({
)}
</div>
<div className="space-y-2">
<Label htmlFor="type">Type</Label>
<Select
name="type"
defaultValue={category.type}
required
onValueChange={setSelectedType}
>
<SelectTrigger>
<SelectValue placeholder="Select a type" />
</SelectTrigger>
<SelectContent>
{CATEGORY_TYPES.map((type) => (
<SelectItem key={type} value={type}>
{type.charAt(0).toUpperCase() +
type.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.type && (
<p className="text-sm text-red-500">
{errors.type}
</p>
)}
{selectedType === 'transfer' && (
<Alert>
<Info className="h-4 w-4 opacity-50" />
<AlertDescription className="text-sm">
Transactions in this category will
not be counted in top expenses or
income. Transfer categories are
mainly used for transactions between
accounts.
</AlertDescription>
</Alert>
)}
</div>
<div className="flex justify-end gap-2">
<Button
type="button"

View File

@ -225,6 +225,38 @@ export default function Categories() {
);
},
},
{
accessorKey: 'type',
header: 'Type',
cell: ({ row }) => {
const type = row.getValue('type') as Category['type'];
const typeConfig = {
income: {
label: 'Income',
className:
'bg-green-50 text-green-700 dark:bg-green-700 dark:text-green-100',
},
expense: {
label: 'Expense',
className:
'bg-red-50 text-red-700 dark:bg-red-700 dark:text-red-100',
},
transfer: {
label: 'Transfer',
className:
'bg-zinc-50 text-zinc-700 dark:bg-zinc-700 dark:text-zinc-100',
},
};
const config = typeConfig[type];
return (
<Badge
className={`${config.className} text-[10px] tracking-widest`}
>
{config.label.toLocaleUpperCase()}
</Badge>
);
},
},
{
id: 'actions',
enableHiding: false,

View File

@ -103,11 +103,16 @@ export const CATEGORY_COLORS = [
export type CategoryColor = (typeof CATEGORY_COLORS)[number];
export const CATEGORY_TYPES = ['income', 'expense', 'transfer'] as const;
export type CategoryType = (typeof CATEGORY_TYPES)[number];
export interface Category {
id: UUID;
name: string;
icon: CategoryIcon;
color: CategoryColor;
type: CategoryType;
}
export function getCategoryColorClasses(color: CategoryColor): {

1
tests/Browser/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
Screenshots/

View File

@ -44,7 +44,7 @@ it('can open create category dialog', function () {
$page->assertSee('Categories settings')
->click('Create Category')
->waitFor('dialog')
->wait(0.5)
->assertSee('Add a new category to organize your transactions')
->assertNoJavascriptErrors();
});
@ -58,14 +58,20 @@ it('can create a new category', function () {
$page->assertSee('Categories settings')
->click('Create Category')
->waitFor('dialog')
->wait(0.5)
->fill('name', 'Entertainment')
->click('Select an icon')
->wait(0.5)
->click('//div[@role="option"][1]')
->wait(0.3)
->click('Film')
->click('Select a color')
->wait(0.5)
->click('//div[@role="option"][contains(., "purple")]')
->wait(0.3)
->click('Select a type')
->wait(0.5)
->click('//div[@role="option"][contains(., "Expense")]')
->wait(0.3)
->click('purple')
->click('button[type="submit"]')
->wait(2)
->assertSee('Entertainment')
@ -74,8 +80,8 @@ it('can create a new category', function () {
$this->assertDatabaseHas('categories', [
'user_id' => $user->id,
'name' => 'Entertainment',
'icon' => 'Film',
'color' => 'purple',
'type' => 'expense',
]);
});
@ -127,13 +133,13 @@ it('can edit an existing category via context menu', function () {
$page = visit('/settings/categories');
$page->assertSee('Old Category')
->click('button[aria-label="Open menu"]')
->wait(0.3)
->rightClick('Old Category')
->wait(0.5)
->click('Edit')
->waitFor('dialog')
->wait(0.5)
->assertSee('Edit Category')
->fill('name', 'Updated Category')
->click('Save')
->click('//button[contains(., "Update")]')
->wait(2)
->assertSee('Updated Category')
->assertNoJavascriptErrors();
@ -143,3 +149,53 @@ it('can edit an existing category via context menu', function () {
'name' => 'Updated Category',
]);
});
it('shows transfer type description when transfer type is selected in create dialog', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
actingAs($user);
$page = visit('/settings/categories');
$page->assertSee('Categories settings')
->click('Create Category')
->wait(1)
->assertSee('Add a new category to organize your transactions')
->assertSee('Select a type')
->click('//button[contains(., "Select a type")]')
->wait(0.5)
->click('//div[@role="option"][contains(., "Transfer")]')
->wait(0.5)
->assertSee('Transactions in this category will not be counted in top expenses or income')
->assertSee('Transfer categories are mainly used for transactions between accounts')
->assertNoJavascriptErrors();
});
it('shows transfer type description when transfer type is selected in edit dialog', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$category = Category::factory()->create([
'user_id' => $user->id,
'name' => 'Test Category',
'icon' => 'Tag',
'color' => 'blue',
'type' => 'expense',
]);
actingAs($user);
$page = visit('/settings/categories');
$page->assertSee('Test Category')
->rightClick('Test Category')
->wait(0.5)
->click('Edit')
->wait(1)
->assertSee('Edit Category')
->click('//button[contains(., "Expense")]')
->wait(0.5)
->click('//div[@role="option"][contains(., "Transfer")]')
->wait(0.5)
->assertSee('Transactions in this category will not be counted in top expenses or income')
->assertSee('Transfer categories are mainly used for transactions between accounts')
->assertNoJavascriptErrors();
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

View File

@ -40,6 +40,7 @@ test('authenticated users can create a category', function () {
'name' => 'Shopping',
'icon' => 'ShoppingBag',
'color' => 'blue',
'type' => 'expense',
];
$response = $this->actingAs($user)->post(route('categories.store'), $categoryData);
@ -51,6 +52,7 @@ test('authenticated users can create a category', function () {
'name' => 'Shopping',
'icon' => 'ShoppingBag',
'color' => 'blue',
'type' => 'expense',
]);
});
@ -60,6 +62,7 @@ test('category name is required', function () {
$response = $this->actingAs($user)->post(route('categories.store'), [
'icon' => 'ShoppingBag',
'color' => 'blue',
'type' => 'expense',
]);
$response->assertSessionHasErrors(['name']);
@ -71,6 +74,7 @@ test('category icon is required', function () {
$response = $this->actingAs($user)->post(route('categories.store'), [
'name' => 'Shopping',
'color' => 'blue',
'type' => 'expense',
]);
$response->assertSessionHasErrors(['icon']);
@ -82,6 +86,7 @@ test('category color is required', function () {
$response = $this->actingAs($user)->post(route('categories.store'), [
'name' => 'Shopping',
'icon' => 'ShoppingBag',
'type' => 'expense',
]);
$response->assertSessionHasErrors(['color']);
@ -94,11 +99,37 @@ test('category color must be valid', function () {
'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]);
@ -107,6 +138,7 @@ test('authenticated users can update their own category', function () {
'name' => 'Updated Name',
'icon' => 'Home',
'color' => 'green',
'type' => 'income',
];
$response = $this->actingAs($user)->patch(
@ -121,6 +153,7 @@ test('authenticated users can update their own category', function () {
'name' => 'Updated Name',
'icon' => 'Home',
'color' => 'green',
'type' => 'income',
]);
});
@ -135,6 +168,7 @@ test('users cannot update categories they do not own', function () {
'name' => 'Updated Name',
'icon' => 'Home',
'color' => 'green',
'type' => 'expense',
]
);
@ -209,6 +243,7 @@ test('category names are unique per user', function () {
'name' => 'Test Category',
'icon' => 'Tag',
'color' => 'red',
'type' => 'expense',
]);
expect($category)->toBeInstanceOf(\App\Models\Category::class);
@ -219,5 +254,6 @@ test('category names are unique per user', function () {
'name' => 'Test Category',
'icon' => 'Tag',
'color' => 'blue',
'type' => 'expense',
]);
});