diff --git a/.gitignore b/.gitignore index afa306b..95d7c30 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,5 @@ yarn-error.log /.idea /.vscode /.zed + +/.github diff --git a/app/DonationStatus.php b/app/DonationStatus.php new file mode 100644 index 0000000..c8716bc --- /dev/null +++ b/app/DonationStatus.php @@ -0,0 +1,12 @@ +validated(); + + $donation = Donation::create([ + 'user_id' => $request->user()->id, + 'amount' => $validated['amount'], + 'currency' => strtoupper($validated['currency']), + 'donated_at' => now(), + ])->refresh(); + + return response()->json([ + 'message' => 'Donation created successfully', + 'donation' => $donation, + ], Response::HTTP_CREATED); + } + + public function update(Donation $donation, UpdateDonationRequest $request): JsonResponse + { + $validated = $request->validated(); + $donation->update($validated); + + return response()->json([ + 'message' => 'Donation updated successfully', + 'donation' => $donation, + ], Response::HTTP_OK); + } + + public function historyByUser(string $user_id): JsonResponse + { + $donations = Donation::where('user_id', $user_id) + ->orderBy('donated_at', 'desc') + ->where('status', DonationStatus::COMPLETED->value) + ->get(); + + return response()->json([ + 'donations' => DonationResource::collection($donations), + ], Response::HTTP_OK); + } +} diff --git a/app/Http/Controllers/RecurringDonationController.php b/app/Http/Controllers/RecurringDonationController.php new file mode 100644 index 0000000..6553cc7 --- /dev/null +++ b/app/Http/Controllers/RecurringDonationController.php @@ -0,0 +1,10 @@ + + */ + public function rules(): array + { + return [ + 'amount' => ['required', 'numeric', 'min:1'], + 'currency' => ['required', 'string', 'size:3'], + // 'status' => ['sometimes', 'string', 'in:cancelled,completed,failed,pending,refunded'], + ]; + } + + /** + * Get custom error messages for validation rules. + * + * @return array + */ + public function messages(): array + { + return [ + 'amount.required' => 'Donation amount is required.', + 'amount.numeric' => 'Donation amount must be a number.', + 'amount.min' => 'Donation amount must be at least 1.', + 'currency.required' => 'Currency is required.', + 'currency.size' => 'Currency must be a 3-letter code.', + // 'status.in' => 'Status must be one of: cancelled, completed, failed, pending, refunded.', + ]; + } +} diff --git a/app/Http/Requests/UpdateDonationRequest.php b/app/Http/Requests/UpdateDonationRequest.php new file mode 100644 index 0000000..e61e7a1 --- /dev/null +++ b/app/Http/Requests/UpdateDonationRequest.php @@ -0,0 +1,39 @@ + + */ + public function rules(): array + { + return [ + 'status' => ['required', 'string', 'in:cancelled,completed,failed,refunded'], + ]; + } + + /** + * Get custom error messages for validation rules. + * + * @return array + */ + public function messages(): array + { + return [ + 'status.in' => 'Status must be one of: cancelled, completed, failed, refunded.', + ]; + } +} diff --git a/app/Http/Resources/DonationCollection.php b/app/Http/Resources/DonationCollection.php new file mode 100644 index 0000000..37c687d --- /dev/null +++ b/app/Http/Resources/DonationCollection.php @@ -0,0 +1,19 @@ + + */ + public function toArray(Request $request): array + { + return parent::toArray($request); + } +} diff --git a/app/Http/Resources/DonationResource.php b/app/Http/Resources/DonationResource.php new file mode 100644 index 0000000..b2c065f --- /dev/null +++ b/app/Http/Resources/DonationResource.php @@ -0,0 +1,29 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'user_id' => $this->user_id, + 'amount' => $this->amount, + 'currency' => $this->currency, + 'status' => $this->status, + 'is_recurring' => $this->recurring_donation_id ? 'true' : 'false', + 'donated_at' => $this->donated_at, + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + ]; + } +} diff --git a/app/Models/Donation.php b/app/Models/Donation.php new file mode 100644 index 0000000..103f9ac --- /dev/null +++ b/app/Models/Donation.php @@ -0,0 +1,39 @@ + */ + use HasFactory; + + /** + * The attributes that are mass assignable. + * + * @var array + */ + protected $fillable = [ + 'user_id', + 'amount', + 'currency', + 'donated_at', + 'status', + 'recurring_donation_id', + ]; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'status' => DonationStatus::class, + ]; + } +} diff --git a/app/Models/RecurringDonation.php b/app/Models/RecurringDonation.php new file mode 100644 index 0000000..2adc1a9 --- /dev/null +++ b/app/Models/RecurringDonation.php @@ -0,0 +1,40 @@ + */ + use HasFactory; + + /** + * The attributes that are mass assignable. + * + * @var array + */ + protected $fillable = [ + 'user_id', + 'amount', + 'currency', + 'schedule', + 'start_date', + 'end_date', + 'status', + ]; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'status' => RecurringDonationStatus::class, + ]; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 3dfbd80..8c65665 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -6,11 +6,12 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use Laravel\Sanctum\HasApiTokens; class User extends Authenticatable { /** @use HasFactory<\Database\Factories\UserFactory> */ - use HasFactory, Notifiable; + use HasApiTokens, HasFactory, Notifiable; /** * The attributes that are mass assignable. diff --git a/app/RecurringDonationStatus.php b/app/RecurringDonationStatus.php new file mode 100644 index 0000000..cce6bfc --- /dev/null +++ b/app/RecurringDonationStatus.php @@ -0,0 +1,10 @@ +withRouting( web: __DIR__.'/../routes/web.php', + api: __DIR__.'/../routes/api.php', commands: __DIR__.'/../routes/console.php', health: '/up', ) diff --git a/composer.json b/composer.json index 5605c28..5db62ee 100644 --- a/composer.json +++ b/composer.json @@ -7,10 +7,12 @@ "require": { "php": "^8.2", "laravel/framework": "^11.9", + "laravel/sanctum": "^4.0", "laravel/tinker": "^2.9" }, "require-dev": { "fakerphp/faker": "^1.23", + "laravel/boost": "^1.0", "laravel/pint": "^1.13", "laravel/sail": "^1.26", "mockery/mockery": "^1.6", diff --git a/composer.lock b/composer.lock index a3884ec..6d7cbc9 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "8c804e4a8c3c1ea0e6dd61980c279cdd", + "content-hash": "f7fdffb42fff7fe1b38917b6244b5df3", "packages": [ { "name": "brick/math", @@ -1322,6 +1322,70 @@ }, "time": "2024-09-30T14:27:51+00:00" }, + { + "name": "laravel/sanctum", + "version": "v4.2.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/sanctum.git", + "reference": "fd6df4f79f48a72992e8d29a9c0ee25422a0d677" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/sanctum/zipball/fd6df4f79f48a72992e8d29a9c0ee25422a0d677", + "reference": "fd6df4f79f48a72992e8d29a9c0ee25422a0d677", + "shasum": "" + }, + "require": { + "ext-json": "*", + "illuminate/console": "^11.0|^12.0", + "illuminate/contracts": "^11.0|^12.0", + "illuminate/database": "^11.0|^12.0", + "illuminate/support": "^11.0|^12.0", + "php": "^8.2", + "symfony/console": "^7.0" + }, + "require-dev": { + "mockery/mockery": "^1.6", + "orchestra/testbench": "^9.0|^10.0", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Sanctum\\SanctumServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Sanctum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel Sanctum provides a featherweight authentication system for SPAs and simple APIs.", + "keywords": [ + "auth", + "laravel", + "sanctum" + ], + "support": { + "issues": "https://github.com/laravel/sanctum/issues", + "source": "https://github.com/laravel/sanctum" + }, + "time": "2025-07-09T19:45:24+00:00" + }, { "name": "laravel/serializable-closure", "version": "v1.3.5", @@ -5799,6 +5863,135 @@ }, "time": "2020-07-09T08:09:16+00:00" }, + { + "name": "laravel/boost", + "version": "v1.0.18", + "source": { + "type": "git", + "url": "https://github.com/laravel/boost.git", + "reference": "df2a62b5864759ea8cce8a4b7575b657e9c7d4ab" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/boost/zipball/df2a62b5864759ea8cce8a4b7575b657e9c7d4ab", + "reference": "df2a62b5864759ea8cce8a4b7575b657e9c7d4ab", + "shasum": "" + }, + "require": { + "guzzlehttp/guzzle": "^7.9", + "illuminate/console": "^10.0|^11.0|^12.0", + "illuminate/contracts": "^10.0|^11.0|^12.0", + "illuminate/routing": "^10.0|^11.0|^12.0", + "illuminate/support": "^10.0|^11.0|^12.0", + "laravel/mcp": "^0.1.0", + "laravel/prompts": "^0.1.9|^0.3", + "laravel/roster": "^0.2", + "php": "^8.1|^8.2" + }, + "require-dev": { + "laravel/pint": "^1.14|^1.23", + "mockery/mockery": "^1.6", + "orchestra/testbench": "^8.22.0|^9.0|^10.0", + "pestphp/pest": "^2.0|^3.0", + "phpstan/phpstan": "^2.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Boost\\BoostServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Boost\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Laravel Boost accelerates AI-assisted development to generate high-quality, Laravel-specific code.", + "homepage": "https://github.com/laravel/boost", + "keywords": [ + "ai", + "dev", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/boost/issues", + "source": "https://github.com/laravel/boost" + }, + "time": "2025-08-16T09:10:03+00:00" + }, + { + "name": "laravel/mcp", + "version": "v0.1.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/mcp.git", + "reference": "6d6284a491f07c74d34f48dfd999ed52c567c713" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/mcp/zipball/6d6284a491f07c74d34f48dfd999ed52c567c713", + "reference": "6d6284a491f07c74d34f48dfd999ed52c567c713", + "shasum": "" + }, + "require": { + "illuminate/console": "^10.0|^11.0|^12.0", + "illuminate/contracts": "^10.0|^11.0|^12.0", + "illuminate/http": "^10.0|^11.0|^12.0", + "illuminate/routing": "^10.0|^11.0|^12.0", + "illuminate/support": "^10.0|^11.0|^12.0", + "illuminate/validation": "^10.0|^11.0|^12.0", + "php": "^8.1|^8.2" + }, + "require-dev": { + "laravel/pint": "^1.14", + "orchestra/testbench": "^8.22.0|^9.0|^10.0", + "phpstan/phpstan": "^2.0" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Mcp": "Laravel\\Mcp\\Server\\Facades\\Mcp" + }, + "providers": [ + "Laravel\\Mcp\\Server\\McpServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Mcp\\": "src/", + "Workbench\\App\\": "workbench/app/", + "Laravel\\Mcp\\Tests\\": "tests/", + "Laravel\\Mcp\\Server\\": "src/Server/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "The easiest way to add MCP servers to your Laravel app.", + "homepage": "https://github.com/laravel/mcp", + "keywords": [ + "dev", + "laravel", + "mcp" + ], + "support": { + "issues": "https://github.com/laravel/mcp/issues", + "source": "https://github.com/laravel/mcp" + }, + "time": "2025-08-16T09:50:43+00:00" + }, { "name": "laravel/pint", "version": "v1.18.1", @@ -5865,6 +6058,66 @@ }, "time": "2024-09-24T17:22:50+00:00" }, + { + "name": "laravel/roster", + "version": "v0.2.2", + "source": { + "type": "git", + "url": "https://github.com/laravel/roster.git", + "reference": "67a39bce557a6cb7e7205a2a9d6c464f0e72956f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/roster/zipball/67a39bce557a6cb7e7205a2a9d6c464f0e72956f", + "reference": "67a39bce557a6cb7e7205a2a9d6c464f0e72956f", + "shasum": "" + }, + "require": { + "illuminate/console": "^10.0|^11.0|^12.0", + "illuminate/contracts": "^10.0|^11.0|^12.0", + "illuminate/routing": "^10.0|^11.0|^12.0", + "illuminate/support": "^10.0|^11.0|^12.0", + "php": "^8.1|^8.2" + }, + "require-dev": { + "laravel/pint": "^1.14", + "mockery/mockery": "^1.6", + "orchestra/testbench": "^8.22.0|^9.0|^10.0", + "pestphp/pest": "^2.0|^3.0", + "phpstan/phpstan": "^2.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Roster\\RosterServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Roster\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Detect packages & approaches in use within a Laravel project", + "homepage": "https://github.com/laravel/roster", + "keywords": [ + "dev", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/roster/issues", + "source": "https://github.com/laravel/roster" + }, + "time": "2025-07-24T12:31:13+00:00" + }, { "name": "laravel/sail", "version": "v1.35.0", @@ -7756,12 +8009,12 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": {}, "prefer-stable": true, "prefer-lowest": false, "platform": { "php": "^8.2" }, - "platform-dev": [], + "platform-dev": {}, "plugin-api-version": "2.6.0" } diff --git a/config/sanctum.php b/config/sanctum.php new file mode 100644 index 0000000..44527d6 --- /dev/null +++ b/config/sanctum.php @@ -0,0 +1,84 @@ + explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf( + '%s%s', + 'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1', + Sanctum::currentApplicationUrlWithPort(), + // Sanctum::currentRequestHost(), + ))), + + /* + |-------------------------------------------------------------------------- + | Sanctum Guards + |-------------------------------------------------------------------------- + | + | This array contains the authentication guards that will be checked when + | Sanctum is trying to authenticate a request. If none of these guards + | are able to authenticate the request, Sanctum will use the bearer + | token that's present on an incoming request for authentication. + | + */ + + 'guard' => ['web'], + + /* + |-------------------------------------------------------------------------- + | Expiration Minutes + |-------------------------------------------------------------------------- + | + | This value controls the number of minutes until an issued token will be + | considered expired. This will override any values set in the token's + | "expires_at" attribute, but first-party sessions are not affected. + | + */ + + 'expiration' => null, + + /* + |-------------------------------------------------------------------------- + | Token Prefix + |-------------------------------------------------------------------------- + | + | Sanctum can prefix new tokens in order to take advantage of numerous + | security scanning initiatives maintained by open source platforms + | that notify developers if they commit tokens into repositories. + | + | See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning + | + */ + + 'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''), + + /* + |-------------------------------------------------------------------------- + | Sanctum Middleware + |-------------------------------------------------------------------------- + | + | When authenticating your first-party SPA with Sanctum you may need to + | customize some of the middleware Sanctum uses while processing the + | request. You may change the middleware listed below as required. + | + */ + + 'middleware' => [ + 'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class, + 'encrypt_cookies' => Illuminate\Cookie\Middleware\EncryptCookies::class, + 'validate_csrf_token' => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class, + ], + +]; diff --git a/database/factories/DonationFactory.php b/database/factories/DonationFactory.php new file mode 100644 index 0000000..8d209a9 --- /dev/null +++ b/database/factories/DonationFactory.php @@ -0,0 +1,27 @@ + + */ +class DonationFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'amount' => fake()->randomFloat(nbMaxDecimals: 2, min: 1, max: 500), + 'currency' => 'CAD', + 'donated_at' => now(), + ]; + } +} diff --git a/database/factories/RecurringDonationFactory.php b/database/factories/RecurringDonationFactory.php new file mode 100644 index 0000000..268a17b --- /dev/null +++ b/database/factories/RecurringDonationFactory.php @@ -0,0 +1,23 @@ + + */ +class RecurringDonationFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + // + ]; + } +} diff --git a/database/migrations/2025_09_28_212214_create_donations_table.php b/database/migrations/2025_09_28_212214_create_donations_table.php new file mode 100644 index 0000000..70c6a4c --- /dev/null +++ b/database/migrations/2025_09_28_212214_create_donations_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignIdFor(User::class); + $table->decimal('amount', total: 8, places: 2); + $table->string('currency', length: 3); + $table->timestampTz('donated_at'); + $table->enum('status', array_column(DonationStatus::cases(), 'value'))->default(DonationStatus::PENDING->value); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('donations'); + } +}; diff --git a/database/migrations/2025_09_29_011914_create_personal_access_tokens_table.php b/database/migrations/2025_09_29_011914_create_personal_access_tokens_table.php new file mode 100644 index 0000000..40ff706 --- /dev/null +++ b/database/migrations/2025_09_29_011914_create_personal_access_tokens_table.php @@ -0,0 +1,33 @@ +id(); + $table->morphs('tokenable'); + $table->text('name'); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable()->index(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('personal_access_tokens'); + } +}; diff --git a/database/migrations/2025_09_29_144742_create_recurring_donations_table.php b/database/migrations/2025_09_29_144742_create_recurring_donations_table.php new file mode 100644 index 0000000..806c0c0 --- /dev/null +++ b/database/migrations/2025_09_29_144742_create_recurring_donations_table.php @@ -0,0 +1,59 @@ +id(); + $table->foreignIdFor(User::class); + $table->decimal('amount', total: 8, places: 2); + $table->string('currency', length: 3); + $table->string('schedule'); + $table->timestampTz('start_date'); + $table->timestampTz('end_date')->nullable(); + $table->enum( + 'status', + array_column(RecurringDonationStatus::cases(), 'value') + )->default(RecurringDonationStatus::ACTIVE->value); + $table->timestamps(); + }); + + // Add a check constraint for cron format: ^([*d/,-]+s){4}[*d/,-]+$ + DB::statement( + "ALTER TABLE recurring_donations + ADD CONSTRAINT chk_schedule_cron_format + CHECK (schedule REGEXP '^([\\*\\d\\/,\\-]+\\s){4}[\\*\\d\\/,\\-]+$')" + ); + + Schema::table('donations', function (Blueprint $table) { + $table->foreignId('recurring_donation_id') + ->nullable() + ->constrained('recurring_donations') + ->cascadeOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('donations', function (Blueprint $table) { + $table->dropForeign(['recurring_donation_id']); + $table->dropColumn('recurring_donation_id'); + }); + + Schema::dropIfExists('recurring_donations'); + } +}; diff --git a/database/seeders/DonationSeeder.php b/database/seeders/DonationSeeder.php new file mode 100644 index 0000000..946ea7b --- /dev/null +++ b/database/seeders/DonationSeeder.php @@ -0,0 +1,17 @@ +user(); +})->middleware('auth:sanctum'); + +// @todo group routes belonging to DonationController +Route::controller(DonationController::class)->group(function () { + Route::post('/donations', 'store')->middleware('auth:sanctum'); + Route::patch('/donations/{donation}', 'update')->middleware('auth:sanctum'); + Route::get('/donations/history/{user_id}', 'historyByUser')->middleware('auth:sanctum')->whereNumber('user_id'); +}); + diff --git a/tests/Feature/DonationTest.php b/tests/Feature/DonationTest.php new file mode 100644 index 0000000..1b93b3e --- /dev/null +++ b/tests/Feature/DonationTest.php @@ -0,0 +1,149 @@ +create(); + + $this->assertDatabaseCount('users', 1); + + $this->assertDatabaseCount('donations', 1); + $this->assertDatabaseHas('donations', [ + 'id' => $donation->id, + 'user_id' => $donation->user_id, + 'amount' => $donation->amount, + 'currency' => $donation->currency, + 'status' => DonationStatus::PENDING->value, + ]); + + $donation->update([ + 'status' => DonationStatus::COMPLETED->value, + ]); + + $this->assertDatabaseHas('donations', [ + 'id' => $donation->id, + 'status' => DonationStatus::COMPLETED->value, + ]); + } + + public function test_donation_edition_via_api(): void + { + // ARRANGE 1 + /** @var User $user */ + $user = User::factory()->create(); + $token = $user->createToken('TestToken')->plainTextToken; + + // create a donation + $payloadCreate = [ + 'amount' => '100.00', + 'currency' => 'CAD', + ]; + + $this->assertDatabaseCount('users', 1); + $this->assertDatabaseCount('donations', 0); + + // ACT 1 + $response = $this->actingAs($user)->postJson('/api/donations', $payloadCreate); + + // ASSERT 1 + $response->assertStatus(Response::HTTP_CREATED) + ->assertJson([ + 'message' => 'Donation created successfully', + 'donation' => [ + 'id' => 2, + 'user_id' => $user->id, + 'status' => DonationStatus::PENDING->value, + ] + $payloadCreate, + ]); + + $this->assertDatabaseCount('donations', 1); + $this->assertDatabaseHas('donations', [ + 'user_id' => $user->id, + 'status' => DonationStatus::PENDING->value, + ] + $payloadCreate); + + // ARRANGE 2 + // update a donation status + $payloadUpdate = [ + 'status' => DonationStatus::COMPLETED->value, + ]; + + // ACT 2 + $response = $this->actingAs($user)->patchJson("/api/donations/2", $payloadUpdate); + + // ASSERT 2 + $response->assertStatus(Response::HTTP_OK) + ->assertJson([ + 'message' => 'Donation updated successfully', + 'donation' => [ + 'id' => 2, + 'user_id' => $user->id, + 'status' => DonationStatus::COMPLETED->value, + ] + $payloadCreate + $payloadUpdate + ]); + + $this->assertDatabaseCount('donations', 1); + $this->assertDatabaseHas('donations', [ + 'user_id' => $user->id, + 'status' => DonationStatus::COMPLETED->value, + ] + $payloadCreate + $payloadUpdate); + } + + public function test_donation_history_by_user(): void + { + // ARRANGE + /** @var User $user */ + $user = User::factory()->create(); + $token = $user->createToken('TestToken')->plainTextToken; + $otherUser = User::factory()->create(); + + // Completed donations for $user + $completedDonations = Donation::factory() + ->count(2) + ->create([ + 'user_id' => $user->id, + 'status' => DonationStatus::COMPLETED->value, + ]); + + // Pending donation for $user + Donation::factory()->create([ + 'user_id' => $user->id, + 'status' => DonationStatus::PENDING->value, + ]); + + // Completed donation for another user + Donation::factory()->create([ + 'user_id' => $otherUser->id, + 'status' => DonationStatus::COMPLETED->value, + ]); + + // ACT + $response = $this->actingAs($user)->getJson("/api/donations/history/{$user->id}"); + + // ASSERT + $response->assertStatus(Response::HTTP_OK) + ->assertJsonCount(2, 'donations') + ->assertJsonFragment([ + 'user_id' => $user->id, + 'status' => DonationStatus::COMPLETED->value, + ]); + + // Ensure only completed donations for $user are returned + foreach ($response->json('donations') as $donation) { + $this->assertEquals($user->id, $donation['user_id']); + $this->assertEquals(DonationStatus::COMPLETED->value, $donation['status']); + } + } +}