From e4568e50faf800d7ca31086ee77c00c73bbce94d Mon Sep 17 00:00:00 2001 From: rakib Date: Sun, 2 Jun 2024 19:59:33 +0600 Subject: [PATCH 1/2] added expense model, factory, migration, request validation, policy, observer, controller, api route & test --- app/Http/Controllers/ExpenseController.php | 57 ++++++ app/Http/Requests/StoreExpenseRequest.php | 35 ++++ app/Models/Expense.php | 42 +++++ app/Observers/ExpenseObserver.php | 18 ++ app/Policies/ExpensePolicy.php | 65 +++++++ app/Providers/AppServiceProvider.php | 3 + database/factories/ExpenseFactory.php | 32 ++++ ...024_06_02_122537_create_expenses_table.php | 45 +++++ database/seeders/PermissionSeeder.php | 6 + routes/api.php | 2 + tests/Feature/ExpenseTest.php | 162 ++++++++++++++++++ 11 files changed, 467 insertions(+) create mode 100644 app/Http/Controllers/ExpenseController.php create mode 100644 app/Http/Requests/StoreExpenseRequest.php create mode 100644 app/Models/Expense.php create mode 100644 app/Observers/ExpenseObserver.php create mode 100644 app/Policies/ExpensePolicy.php create mode 100644 database/factories/ExpenseFactory.php create mode 100644 database/migrations/2024_06_02_122537_create_expenses_table.php create mode 100644 tests/Feature/ExpenseTest.php diff --git a/app/Http/Controllers/ExpenseController.php b/app/Http/Controllers/ExpenseController.php new file mode 100644 index 0000000..864b4c0 --- /dev/null +++ b/app/Http/Controllers/ExpenseController.php @@ -0,0 +1,57 @@ +latest()->paginate(20); + } + + public function show(Expense $expense) + { + Gate::authorize('view', $expense); + + $expense->load(['account', 'expenseCategory', 'paymentMethod', 'creator']); + + return $expense; + } + + public function store(StoreExpenseRequest $request) + { + Gate::authorize('create', Expense::class); + + Expense::create($request->validated()); + + return response()->json(['message' => 'Expense created successfully.'], 201); + } + + public function update(Request $request, Expense $expense) + { + Gate::authorize('update', $expense); + + $expense->update($request->all()); + + return response()->json(['message' => 'Expense updated successfully.'], 200); + } + + public function destroy(Expense $expense) + { + Gate::authorize('delete', $expense); + + $expense->deleted_by = auth()->id(); + $expense->save(); + + $expense->delete(); // soft delete + + return response()->json(['message' => 'Expense deleted successfully.'], 204); + } +} diff --git a/app/Http/Requests/StoreExpenseRequest.php b/app/Http/Requests/StoreExpenseRequest.php new file mode 100644 index 0000000..9c9e509 --- /dev/null +++ b/app/Http/Requests/StoreExpenseRequest.php @@ -0,0 +1,35 @@ +|string> + */ + public function rules(): array + { + return [ + 'name' => 'required', + 'expense_category_id' => 'required|integer|exists:expense_categories,id', + 'account_id' => 'required|integer|exists:accounts,id', + 'payment_method_id' => 'required|integer|exists:payment_methods,id', + 'expense_date' => 'required|date', + 'amount' => 'required|numeric', + 'description' => 'nullable', + ]; + } +} diff --git a/app/Models/Expense.php b/app/Models/Expense.php new file mode 100644 index 0000000..b9a7dc4 --- /dev/null +++ b/app/Models/Expense.php @@ -0,0 +1,42 @@ +belongsTo(ExpenseCategory::class)->select(['id', 'name']); + } + + public function account() + { + return $this->belongsTo(Account::class)->select(['id', 'name', 'balance']); + } + + public function paymentMethod() + { + return $this->belongsTo(PaymentMethod::class)->select(['id', 'name']); + } + + public function creator() + { + return $this->belongsTo(User::class, 'created_by')->select(['id', 'name']); + } +} diff --git a/app/Observers/ExpenseObserver.php b/app/Observers/ExpenseObserver.php new file mode 100644 index 0000000..28a7041 --- /dev/null +++ b/app/Observers/ExpenseObserver.php @@ -0,0 +1,18 @@ +created_by = auth()->id(); + } + + public function updating(Expense $expense) + { + $expense->updated_by = auth()->id(); + } +} diff --git a/app/Policies/ExpensePolicy.php b/app/Policies/ExpensePolicy.php new file mode 100644 index 0000000..d3309aa --- /dev/null +++ b/app/Policies/ExpensePolicy.php @@ -0,0 +1,65 @@ +can('expense-list'); + } + + /** + * Determine whether the user can view the model. + */ + public function view(User $user, Expense $expense): bool + { + return $user->can('expense-list'); + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): bool + { + return $user->can('expense-create'); + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, Expense $expense): bool + { + return $user->can('expense-edit'); + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, Expense $expense): bool + { + return $user->can('expense-delete'); + } + + /** + * Determine whether the user can restore the model. + */ + public function restore(User $user, Expense $expense): bool + { + return $user->can('expense-restore'); + } + + /** + * Determine whether the user can permanently delete the model. + */ + public function forceDelete(User $user, Expense $expense): bool + { + return $user->can('expense-force-delete'); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index c78517e..94f7bea 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -5,12 +5,14 @@ use App\Models\Account; use App\Models\Deposit; use App\Models\DepositCategory; +use App\Models\Expense; use App\Models\ExpenseCategory; use App\Models\PaymentMethod; use App\Observers\AccountObserver; use App\Observers\DepositCategoryObserver; use App\Observers\DepositObserver; use App\Observers\ExpenseCategoryObserver; +use App\Observers\ExpenseObserver; use App\Observers\PaymentMethodObserver; use Illuminate\Support\ServiceProvider; @@ -34,5 +36,6 @@ public function boot(): void Deposit::observe(DepositObserver::class); PaymentMethod::observe(PaymentMethodObserver::class); ExpenseCategory::observe(ExpenseCategoryObserver::class); + Expense::observe(ExpenseObserver::class); } } diff --git a/database/factories/ExpenseFactory.php b/database/factories/ExpenseFactory.php new file mode 100644 index 0000000..d43391a --- /dev/null +++ b/database/factories/ExpenseFactory.php @@ -0,0 +1,32 @@ + + */ +class ExpenseFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'name' => fake()->sentence(3), + 'expense_category_id' => ExpenseCategory::factory(), + 'account_id' => Account::factory(), + 'payment_method_id' => PaymentMethod::factory(), + 'amount' => fake()->numberBetween(100, 1000), + 'expense_date' => fake()->date(), + 'description' => fake()->sentence(5), + ]; + } +} diff --git a/database/migrations/2024_06_02_122537_create_expenses_table.php b/database/migrations/2024_06_02_122537_create_expenses_table.php new file mode 100644 index 0000000..f9e311b --- /dev/null +++ b/database/migrations/2024_06_02_122537_create_expenses_table.php @@ -0,0 +1,45 @@ +id(); + $table->string('name'); + $table->unsignedBigInteger('expense_category_id'); + $table->unsignedBigInteger('account_id'); + $table->unsignedBigInteger('payment_method_id'); + $table->decimal('amount', 8, 2); + $table->date('expense_date'); + $table->text('description')->nullable(); + $table->unsignedBigInteger('created_by'); + $table->unsignedBigInteger('updated_by')->nullable(); + $table->unsignedBigInteger('deleted_by')->nullable(); + $table->softDeletes(); + $table->timestamps(); + + $table->foreign('expense_category_id')->references('id')->on('expense_categories')->onDelete('cascade'); + $table->foreign('account_id')->references('id')->on('accounts')->onDelete('cascade'); + $table->foreign('payment_method_id')->references('id')->on('payment_methods')->onDelete('cascade'); + $table->foreign('created_by')->references('id')->on('users')->onDelete('cascade'); + $table->foreign('updated_by')->references('id')->on('users')->onDelete('cascade'); + $table->foreign('deleted_by')->references('id')->on('users')->onDelete('cascade'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('expenses'); + } +}; diff --git a/database/seeders/PermissionSeeder.php b/database/seeders/PermissionSeeder.php index 3149b89..f57bd2e 100644 --- a/database/seeders/PermissionSeeder.php +++ b/database/seeders/PermissionSeeder.php @@ -54,6 +54,12 @@ public function run(): void 'expense-category-delete', 'expense-category-restore', 'expense-category-force-delete', + 'expense-list', + 'expense-create', + 'expense-edit', + 'expense-delete', + 'expense-restore', + 'expense-force-delete', ]; foreach ($permissions as $permission) { diff --git a/routes/api.php b/routes/api.php index 4099c2c..6fefef4 100644 --- a/routes/api.php +++ b/routes/api.php @@ -5,6 +5,7 @@ use App\Http\Controllers\DepositCategoryController; use App\Http\Controllers\DepositController; use App\Http\Controllers\ExpenseCategoryController; +use App\Http\Controllers\ExpenseController; use App\Http\Controllers\PaymentMethodController; use Illuminate\Support\Facades\Route; @@ -16,4 +17,5 @@ Route::apiResource('deposits', DepositController::class); Route::apiResource('paymentMethods', PaymentMethodController::class); Route::apiResource('expenseCategories', ExpenseCategoryController::class); + Route::apiResource('expenses', ExpenseController::class); }); diff --git a/tests/Feature/ExpenseTest.php b/tests/Feature/ExpenseTest.php new file mode 100644 index 0000000..6cbd92b --- /dev/null +++ b/tests/Feature/ExpenseTest.php @@ -0,0 +1,162 @@ +user = User::factory()->create(); + Sanctum::actingAs($this->user); + + $this->artisan('db:seed', ['--class' => PermissionSeeder::class]); + $this->app->make(\Spatie\Permission\PermissionRegistrar::class)->forgetCachedPermissions(); + } + + public function test_user_can_create_expense() + { + $this->user->givePermissionTo('expense-create'); + + $expense = Expense::factory()->make(); + + $response = $this->post(route('expenses.store'), $expense->toArray()); + + $response->assertStatus(201); + + $this->assertDatabaseHas('expenses', $expense->toArray()); + } + + public function test_user_can_update_expense() + { + $this->user->givePermissionTo('expense-edit'); + + $expense = Expense::factory()->create(); + + $response = $this->put(route('expenses.update', $expense->id), [ + 'name' => 'Updated Name', + ]); + + $response->assertStatus(200); + + $this->assertDatabaseHas('expenses', [ + 'id' => $expense->id, + 'name' => 'Updated Name', + 'updated_by' => auth()->id(), + ]); + } + + public function test_user_can_delete_expense() + { + $this->user->givePermissionTo('expense-delete'); + + $expense = Expense::factory()->create(); + + $response = $this->delete(route('expenses.destroy', $expense->id)); + + $response->assertStatus(204); + + $this->assertSoftDeleted('expenses', [ + 'id' => $expense->id, + 'deleted_by' => auth()->id(), + ]); + } + + public function test_user_can_read_all_expenses() + { + $this->user->givePermissionTo('expense-list'); + + Expense::factory(10)->create(); + + $response = $this->get(route('expenses.index')); + + $response->assertStatus(200); + + $response->assertJsonCount(10, 'data'); + + $response->assertJsonStructure([ + 'data' => [ + '*' => [ + 'id', + 'name', + 'amount', + 'account_id', + 'expense_category_id', + 'payment_method_id', + 'created_at', + 'updated_at', + 'created_by', + 'updated_by', + 'account' => [ + 'id', + 'name', + ], + 'expense_category' => [ + 'id', + 'name', + ], + 'payment_method' => [ + 'id', + 'name', + ], + 'creator'=> [ + 'id', + 'name' + ], + ], + ], + ]); + } + + public function test_user_can_read_one_expense() + { + $this->user->givePermissionTo('expense-list'); + + $expense = Expense::factory()->create(); + + $response = $this->get(route('expenses.show', $expense->id)); + + $response->assertStatus(200); + + $response->assertJsonStructure([ + 'id', + 'name', + 'amount', + 'account_id', + 'expense_category_id', + 'payment_method_id', + 'created_at', + 'updated_at', + 'created_by', + 'updated_by', + 'account' => [ + 'id', + 'name', + ], + 'expense_category' => [ + 'id', + 'name', + ], + 'payment_method' => [ + 'id', + 'name', + ], + 'creator'=> [ + 'id', + 'name' + ], + ]); + } +} From d89705d2f3bd3f362a02eea984a3b592e5cba8a8 Mon Sep 17 00:00:00 2001 From: rakib Date: Sun, 2 Jun 2024 20:00:11 +0600 Subject: [PATCH 2/2] linted expense test --- tests/Feature/ExpenseTest.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/Feature/ExpenseTest.php b/tests/Feature/ExpenseTest.php index 6cbd92b..1f84302 100644 --- a/tests/Feature/ExpenseTest.php +++ b/tests/Feature/ExpenseTest.php @@ -111,9 +111,9 @@ public function test_user_can_read_all_expenses() 'id', 'name', ], - 'creator'=> [ + 'creator' => [ 'id', - 'name' + 'name', ], ], ], @@ -153,9 +153,9 @@ public function test_user_can_read_one_expense() 'id', 'name', ], - 'creator'=> [ + 'creator' => [ 'id', - 'name' + 'name', ], ]); }