diff --git a/README.md b/README.md index d5ed6b4..08c9c67 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ Make sure a user can't favorite himself. > Please update the following line in this `README.md` file to include your estimate of the time required for completion. -Estimated Time Required: [Your Estimate Here] +Estimated Time Required: 2 hours > After updating the estimate and right before you start coding, commit your changes using the following command: `git add README.md && git commit -m "Task 1 estimated" && git push` @@ -110,7 +110,7 @@ As in the previous task, please make sure to add the relevant tests in `tests/Fe > Please update the following line in this `README.md` file to include your estimate of the time required for completion. -Estimated Time Required: [Your Estimate Here] +Estimated Time Required: 1 hour > After updating the estimate and right before you start coding, commit your changes using the following command: `git add README.md && git commit -m "Task 2 estimated" && git push` @@ -138,7 +138,7 @@ In addition to the implementation, please ensure comprehensive test coverage for > Please update the following line in this `README.md` file to include your estimate of the time required for completion. -Estimated Time Required: [Your Estimate Here] +Estimated Time Required: 35 minutes > After updating the estimate and right before you start coding, commit your changes using the following command: `git add README.md && git commit -m "Task 3 estimated" && git push` @@ -162,7 +162,7 @@ Ensure that the corresponsing tests are provided for this feature. > Please update the following line in this `README.md` file to include your estimate of the time required for completion. -Estimated Time Required: [Your Estimate Here] +Estimated Time Required: 35 minutes > After updating the estimate and right before you start coding, commit your changes using the following command: `git add README.md && git commit -m "Task 4 estimated" && git push` @@ -184,7 +184,7 @@ Once an image is attached, the endpoint should save it appropriately. The URL of > Please update the following line in this `README.md` file to include your estimate of the time required for completion. -Estimated Time Required: [Your Estimate Here] +Estimated Time Required: 30 minutes > After updating the estimate and right before you start coding, commit your changes using the following command: `git add README.md && git commit -m "Extra Task estimated" && git push` @@ -199,5 +199,3 @@ Estimated Time Required: [Your Estimate Here] Congratulations! You completed the first part of this interview coding challenge. We'll now leave the API behind and enter the front-end realm. Please go to the front-end codebase and follow the instructions you'll find on the `README`. Thank you and good luck! - - diff --git a/app/Console/Commands/ImportUsersFromApi.php b/app/Console/Commands/ImportUsersFromApi.php new file mode 100644 index 0000000..e1bb5e2 --- /dev/null +++ b/app/Console/Commands/ImportUsersFromApi.php @@ -0,0 +1,128 @@ +argument('url'); + $limit = $this->option('limit'); + + $this->info("Importing users from {$url}"); + + try { + // Fetch users from the API + $response = Http::get($url); + + if ($response->failed()) { + $this->error("Failed to fetch users from the API: {$response->status()}"); + return 1; + } + + $users = $response->json(); + + if (empty($users)) { + $this->warn("No users found in the API response."); + return 0; + } + + // Apply limit if specified + if ($limit && is_numeric($limit)) { + $users = array_slice($users, 0, (int) $limit); + $this->info("Limiting import to {$limit} users."); + } + + $this->info("Found " . count($users) . " users to import."); + + $importedCount = 0; + $skippedCount = 0; + $errorCount = 0; + + $progressBar = $this->output->createProgressBar(count($users)); + $progressBar->start(); + + foreach ($users as $userData) { + // Validate user data + $validator = Validator::make($userData, [ + 'name' => 'required|string|max:255', + 'email' => 'required|string|email|max:255', + ]); + + if ($validator->fails()) { + $this->newLine(); + $this->warn("Skipping invalid user data: " . json_encode($userData)); + $errorCount++; + $progressBar->advance(); + continue; + } + + // Check if user already exists + $existingUser = User::where('email', $userData['email'])->first(); + + if ($existingUser) { + $skippedCount++; + $progressBar->advance(); + continue; + } + + // Create new user + try { + User::create([ + 'name' => $userData['name'], + 'email' => $userData['email'], + 'password' => Hash::make(Str::random(16)), // Generate a random password + 'email_verified_at' => now(), + ]); + + $importedCount++; + } catch (\Exception $e) { + $this->newLine(); + $this->error("Error creating user {$userData['email']}: " . $e->getMessage()); + $errorCount++; + } + + $progressBar->advance(); + } + + $progressBar->finish(); + $this->newLine(2); + + $this->info("Import completed:"); + $this->info("- Imported: {$importedCount} users"); + $this->info("- Skipped (already exist): {$skippedCount} users"); + $this->info("- Errors: {$errorCount} users"); + + return 0; + } catch (\Exception $e) { + $this->error("An error occurred: " . $e->getMessage()); + return 1; + } + } +} diff --git a/app/Http/Controllers/FavoriteController.php b/app/Http/Controllers/FavoriteController.php index e3af8c5..92dc7fe 100644 --- a/app/Http/Controllers/FavoriteController.php +++ b/app/Http/Controllers/FavoriteController.php @@ -3,9 +3,12 @@ namespace App\Http\Controllers; use App\Models\Post; +use App\Models\User; use Illuminate\Http\Request; use App\Http\Requests\CreateFavoriteRequest; use Illuminate\Http\Response; +use App\Http\Resources\FavoriteResource; +use App\Http\Resources\FavoritesCollection; /** * @group Favorites @@ -16,20 +19,55 @@ class FavoriteController extends Controller { public function index(Request $request) { - $favorites = $request->user()->favorites; - return FavoriteResource::collection($favorites); + $favorites = $request->user()->favorites()->with('favoritable')->get(); + return new FavoritesCollection($favorites); } - public function store(CreateFavoriteRequest $request, Post $post) + public function storePost(CreateFavoriteRequest $request, Post $post) { - $request->user()->favorites()->create(['post_id' => $post->id]); + $request->user()->favorites()->create([ + 'favoritable_type' => Post::class, + 'favoritable_id' => $post->id + ]); return response()->noContent(Response::HTTP_CREATED); } - public function destroy(Request $request, Post $post) + public function destroyPost(Request $request, Post $post) { - $favorite = $request->user()->favorites()->where('post_id', $post->id)->firstOrFail(); + $favorite = $request->user()->favorites() + ->where('favoritable_type', Post::class) + ->where('favoritable_id', $post->id) + ->firstOrFail(); + + $favorite->delete(); + + return response()->noContent(); + } + + public function storeUser(Request $request, User $user) + { + // Prevent users from favoriting themselves + if ($request->user()->id === $user->id) { + return response()->json([ + 'message' => 'You cannot favorite yourself' + ], Response::HTTP_BAD_REQUEST); + } + + $request->user()->favorites()->create([ + 'favoritable_type' => User::class, + 'favoritable_id' => $user->id + ]); + + return response()->noContent(Response::HTTP_CREATED); + } + + public function destroyUser(Request $request, User $user) + { + $favorite = $request->user()->favorites() + ->where('favoritable_type', User::class) + ->where('favoritable_id', $user->id) + ->firstOrFail(); $favorite->delete(); diff --git a/app/Http/Controllers/PostController.php b/app/Http/Controllers/PostController.php index 37812c5..d6285ce 100644 --- a/app/Http/Controllers/PostController.php +++ b/app/Http/Controllers/PostController.php @@ -4,9 +4,13 @@ use App\Http\Resources\PostResource; use App\Models\Post; +use App\Models\User; use App\Http\Requests\CreatePostRequest; use App\Http\Requests\UpdatePostRequest; use App\Http\Requests\DestroyPostRequest; +use App\Notifications\NewPostByFavoritedUser; +use Illuminate\Support\Facades\Notification; +use Illuminate\Support\Facades\Storage; /** * @group Posts @@ -24,13 +28,24 @@ public function index() public function store(CreatePostRequest $request) { $user = $request->user(); - - // Create a new post - $post = Post::create([ + $postData = [ 'title' => $request->input('title'), 'body' => $request->input('body'), 'user_id' => $user->id, - ]); + ]; + + // Handle image upload if present + if ($request->hasFile('image')) { + $image = $request->file('image'); + $imagePath = $image->store('posts', 'public'); + $postData['image_path'] = $imagePath; + } + + // Create a new post + $post = Post::create($postData); + + // Find all users who have favorited this user + $this->notifyFollowers($user, $post); return new PostResource($post); } @@ -42,18 +57,54 @@ public function show(Post $post) public function update(UpdatePostRequest $request, Post $post) { - $post->update([ + $postData = [ 'title' => $request->input('title'), 'body' => $request->input('body'), - ]); + ]; + + // Handle image upload if present + if ($request->hasFile('image')) { + // Delete old image if exists + if ($post->image_path) { + Storage::disk('public')->delete($post->image_path); + } + + $image = $request->file('image'); + $imagePath = $image->store('posts', 'public'); + $postData['image_path'] = $imagePath; + } + + $post->update($postData); return new PostResource($post); } public function destroy(DestroyPostRequest $request, Post $post) { + // Delete associated image if exists + if ($post->image_path) { + Storage::disk('public')->delete($post->image_path); + } + $post->delete(); return response()->noContent(); } + + /** + * Notify followers about a new post + */ + protected function notifyFollowers(User $author, Post $post) + { + // Find all users who have favorited this author + $followers = User::whereHas('favorites', function ($query) use ($author) { + $query->where('favoritable_type', User::class) + ->where('favoritable_id', $author->id); + })->get(); + + // Send notifications asynchronously + if ($followers->count() > 0) { + Notification::send($followers, new NewPostByFavoritedUser($post, $author)); + } + } } diff --git a/app/Http/Requests/CreatePostRequest.php b/app/Http/Requests/CreatePostRequest.php index 368a9f9..fd01610 100644 --- a/app/Http/Requests/CreatePostRequest.php +++ b/app/Http/Requests/CreatePostRequest.php @@ -16,6 +16,7 @@ public function rules() return [ 'title' => 'required|string|max:255', 'body' => 'required|string', + 'image' => 'nullable|image|mimes:jpg,jpeg,png,gif,webp|max:5120', // 5MB max ]; } } diff --git a/app/Http/Resources/FavoriteResource.php b/app/Http/Resources/FavoriteResource.php index 0d32435..865daca 100644 --- a/app/Http/Resources/FavoriteResource.php +++ b/app/Http/Resources/FavoriteResource.php @@ -3,12 +3,34 @@ namespace App\Http\Resources; use Illuminate\Http\Request; -use Illuminate\Http\Resources\Json\ResourceCollection; +use Illuminate\Http\Resources\Json\JsonResource; +use App\Models\Post; +use App\Models\User; -class FavoriteResource extends ResourceCollection +class FavoriteResource extends JsonResource { public function toArray(Request $request): array { - return parent::toArray($request); + $data = [ + 'id' => $this->id, + 'user_id' => $this->user_id, + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + ]; + + if ($this->favoritable_type === Post::class) { + $data['favoritable_type'] = 'post'; + $data['favoritable_id'] = $this->favoritable_id; + $data['post'] = new PostResource($this->favoritable); + } elseif ($this->favoritable_type === User::class) { + $data['favoritable_type'] = 'user'; + $data['favoritable_id'] = $this->favoritable_id; + $data['user'] = [ + 'id' => $this->favoritable->id, + 'name' => $this->favoritable->name, + ]; + } + + return $data; } } diff --git a/app/Http/Resources/FavoritesCollection.php b/app/Http/Resources/FavoritesCollection.php new file mode 100644 index 0000000..9ca8229 --- /dev/null +++ b/app/Http/Resources/FavoritesCollection.php @@ -0,0 +1,40 @@ + + */ + public function toArray(Request $request): array + { + $posts = $this->collection + ->filter(fn($favorite) => $favorite->favoritable_type === Post::class) + ->map(fn($favorite) => new PostResource($favorite->favoritable)) + ->values(); + + $users = $this->collection + ->filter(fn($favorite) => $favorite->favoritable_type === User::class) + ->map(fn($favorite) => [ + 'id' => $favorite->favoritable->id, + 'name' => $favorite->favoritable->name, + ]) + ->values(); + + return [ + 'data' => [ + 'posts' => $posts, + 'users' => $users, + ], + ]; + } +} diff --git a/app/Http/Resources/PostResource.php b/app/Http/Resources/PostResource.php index 617177d..5f9b762 100644 --- a/app/Http/Resources/PostResource.php +++ b/app/Http/Resources/PostResource.php @@ -13,6 +13,7 @@ public function toArray(Request $request): array 'id' => $this->id, 'title' => $this->title, 'body' => $this->body, + 'image_url' => $this->image_url, 'user' => new UserResource($this->user), ]; } diff --git a/app/Models/Favorite.php b/app/Models/Favorite.php index 05d0cd8..663d8ea 100644 --- a/app/Models/Favorite.php +++ b/app/Models/Favorite.php @@ -5,18 +5,24 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\MorphTo; class Favorite extends Model { use HasFactory; - protected $fillable = ['post_id', 'user_id']; + protected $fillable = ['post_id', 'user_id', 'favoritable_type', 'favoritable_id']; public function user(): BelongsTo { return $this->belongsTo(User::class); } + public function favoritable(): MorphTo + { + return $this->morphTo(); + } + public function posts(): BelongsTo { return $this->belongsTo(Post::class, 'post_id'); diff --git a/app/Models/Post.php b/app/Models/Post.php index 5a3d00b..1f72e80 100644 --- a/app/Models/Post.php +++ b/app/Models/Post.php @@ -4,15 +4,33 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\MorphMany; class Post extends Model { use HasFactory; - protected $fillable = ['title', 'body', 'user_id']; + protected $fillable = ['title', 'body', 'user_id', 'image_path']; public function user() { return $this->belongsTo(User::class); } + + public function favorites(): MorphMany + { + return $this->morphMany(Favorite::class, 'favoritable'); + } + + protected $appends = ['image_url']; + + /** + * Get the image URL attribute. + * + * @return string|null + */ + public function getImageUrlAttribute() + { + return $this->image_path ? url('storage/' . $this->image_path) : null; + } } diff --git a/app/Models/User.php b/app/Models/User.php index 774d8d1..f11ebef 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -7,6 +7,7 @@ use Illuminate\Notifications\Notifiable; use Laravel\Sanctum\HasApiTokens; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\MorphMany; class User extends Authenticatable { @@ -50,8 +51,23 @@ public function favorites(): HasMany return $this->hasMany(Favorite::class); } + public function favoritedUsers() + { + return $this->favorites()->where('favoritable_type', User::class)->pluck('favoritable_id'); + } + + public function favoritePosts() + { + return $this->favorites()->where('favoritable_type', Post::class)->pluck('favoritable_id'); + } + public function posts(): HasMany { return $this->hasMany(Post::class); } + + public function favoritesMorph(): MorphMany + { + return $this->morphMany(Favorite::class, 'favoritable'); + } } diff --git a/app/Notifications/NewPostByFavoritedUser.php b/app/Notifications/NewPostByFavoritedUser.php new file mode 100644 index 0000000..3f9378c --- /dev/null +++ b/app/Notifications/NewPostByFavoritedUser.php @@ -0,0 +1,83 @@ +post = $post; + $this->author = $author; + } + + /** + * Get the notification's delivery channels. + * + * @return array + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + /** + * Get the mail representation of the notification. + */ + public function toMail(object $notifiable): MailMessage + { + return (new MailMessage) + ->subject("{$this->author->name} has published a new post") + ->greeting("Hello {$notifiable->name}!") + ->line("{$this->author->name} has published a new post:") + ->line("Title: {$this->post->title}") + ->line("Content: " . substr($this->post->body, 0, 100) . (strlen($this->post->body) > 100 ? '...' : '')) + ->action('View Post', url("/posts/{$this->post->id}")) + ->line('Thank you for using Chipper!'); + } + + /** + * Get the array representation of the notification. + * + * @return array + */ + public function toArray(object $notifiable): array + { + return [ + 'post_id' => $this->post->id, + 'post_title' => $this->post->title, + 'author_id' => $this->author->id, + 'author_name' => $this->author->name, + ]; + } + + /** + * Get the post associated with the notification. + */ + public function getPost(): Post + { + return $this->post; + } + + /** + * Get the author associated with the notification. + */ + public function getAuthor(): User + { + return $this->author; + } +} diff --git a/composer.json b/composer.json index 6d73d01..5112878 100644 --- a/composer.json +++ b/composer.json @@ -6,6 +6,7 @@ "license": "MIT", "require": { "php": "^8.1", + "doctrine/dbal": "^3.0", "guzzlehttp/guzzle": "^7.2", "laravel/framework": "^10.10", "laravel/sanctum": "^3.2", diff --git a/composer.lock b/composer.lock index fda1ff7..692a17c 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": "4477714a7403dd041e3acc760354539e", + "content-hash": "5af6b2029b497848e0f8a83f719e2e50", "packages": [ { "name": "brick/math", @@ -136,6 +136,259 @@ }, "time": "2022-10-27T11:44:00+00:00" }, + { + "name": "doctrine/dbal", + "version": "3.10.4", + "source": { + "type": "git", + "url": "https://github.com/doctrine/dbal.git", + "reference": "63a46cb5aa6f60991186cc98c1d1b50c09311868" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/dbal/zipball/63a46cb5aa6f60991186cc98c1d1b50c09311868", + "reference": "63a46cb5aa6f60991186cc98c1d1b50c09311868", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2", + "doctrine/deprecations": "^0.5.3|^1", + "doctrine/event-manager": "^1|^2", + "php": "^7.4 || ^8.0", + "psr/cache": "^1|^2|^3", + "psr/log": "^1|^2|^3" + }, + "conflict": { + "doctrine/cache": "< 1.11" + }, + "require-dev": { + "doctrine/cache": "^1.11|^2.0", + "doctrine/coding-standard": "14.0.0", + "fig/log-test": "^1", + "jetbrains/phpstorm-stubs": "2023.1", + "phpstan/phpstan": "2.1.30", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "9.6.29", + "slevomat/coding-standard": "8.24.0", + "squizlabs/php_codesniffer": "4.0.0", + "symfony/cache": "^5.4|^6.0|^7.0|^8.0", + "symfony/console": "^4.4|^5.4|^6.0|^7.0|^8.0" + }, + "suggest": { + "symfony/console": "For helpful console commands such as SQL execution and import of files." + }, + "bin": [ + "bin/doctrine-dbal" + ], + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\DBAL\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + } + ], + "description": "Powerful PHP database abstraction layer (DBAL) with many features for database schema introspection and management.", + "homepage": "https://www.doctrine-project.org/projects/dbal.html", + "keywords": [ + "abstraction", + "database", + "db2", + "dbal", + "mariadb", + "mssql", + "mysql", + "oci8", + "oracle", + "pdo", + "pgsql", + "postgresql", + "queryobject", + "sasql", + "sql", + "sqlite", + "sqlserver", + "sqlsrv" + ], + "support": { + "issues": "https://github.com/doctrine/dbal/issues", + "source": "https://github.com/doctrine/dbal/tree/3.10.4" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdbal", + "type": "tidelift" + } + ], + "time": "2025-11-29T10:46:08+00:00" + }, + { + "name": "doctrine/deprecations", + "version": "1.1.5", + "source": { + "type": "git", + "url": "https://github.com/doctrine/deprecations.git", + "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38", + "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "phpunit/phpunit": "<=7.5 || >=13" + }, + "require-dev": { + "doctrine/coding-standard": "^9 || ^12 || ^13", + "phpstan/phpstan": "1.4.10 || 2.1.11", + "phpstan/phpstan-phpunit": "^1.0 || ^2", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12", + "psr/log": "^1 || ^2 || ^3" + }, + "suggest": { + "psr/log": "Allows logging deprecations via PSR-3 logger implementation" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Deprecations\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.", + "homepage": "https://www.doctrine-project.org/", + "support": { + "issues": "https://github.com/doctrine/deprecations/issues", + "source": "https://github.com/doctrine/deprecations/tree/1.1.5" + }, + "time": "2025-04-07T20:06:18+00:00" + }, + { + "name": "doctrine/event-manager", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/doctrine/event-manager.git", + "reference": "b680156fa328f1dfd874fd48c7026c41570b9c6e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/event-manager/zipball/b680156fa328f1dfd874fd48c7026c41570b9c6e", + "reference": "b680156fa328f1dfd874fd48c7026c41570b9c6e", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "conflict": { + "doctrine/common": "<2.9" + }, + "require-dev": { + "doctrine/coding-standard": "^12", + "phpstan/phpstan": "^1.8.8", + "phpunit/phpunit": "^10.5", + "vimeo/psalm": "^5.24" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Common\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + }, + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com" + } + ], + "description": "The Doctrine Event Manager is a simple PHP event system that was built to be used with the various Doctrine projects.", + "homepage": "https://www.doctrine-project.org/projects/event-manager.html", + "keywords": [ + "event", + "event dispatcher", + "event manager", + "event system", + "events" + ], + "support": { + "issues": "https://github.com/doctrine/event-manager/issues", + "source": "https://github.com/doctrine/event-manager/tree/2.0.1" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fevent-manager", + "type": "tidelift" + } + ], + "time": "2024-05-22T20:47:39+00:00" + }, { "name": "doctrine/inflector", "version": "2.0.8", @@ -2381,6 +2634,55 @@ ], "time": "2023-02-25T19:38:58+00:00" }, + { + "name": "psr/cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/cache.git", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for caching libraries", + "keywords": [ + "cache", + "psr", + "psr-6" + ], + "support": { + "source": "https://github.com/php-fig/cache/tree/3.0.0" + }, + "time": "2021-02-03T23:26:27+00:00" + }, { "name": "psr/clock", "version": "1.0.0", @@ -8047,12 +8349,12 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": {}, "prefer-stable": true, "prefer-lowest": false, "platform": { "php": "^8.1" }, - "platform-dev": [], - "plugin-api-version": "2.3.0" + "platform-dev": {}, + "plugin-api-version": "2.6.0" } diff --git a/database/factories/FavoriteFactory.php b/database/factories/FavoriteFactory.php index 786eb3c..9ac9d52 100644 --- a/database/factories/FavoriteFactory.php +++ b/database/factories/FavoriteFactory.php @@ -3,6 +3,8 @@ namespace Database\Factories; use App\Models\Favorite; +use App\Models\Post; +use App\Models\User; use Illuminate\Database\Eloquent\Factories\Factory; class FavoriteFactory extends Factory @@ -21,9 +23,29 @@ class FavoriteFactory extends Factory */ public function definition(): array { + $post = Post::factory()->create(); + return [ - 'post_id' => \App\Models\Post::factory(), - 'user_id' => \App\Models\User::factory(), + 'post_id' => $post->id, + 'user_id' => User::factory(), + 'favoritable_type' => Post::class, + 'favoritable_id' => $post->id, ]; } + + /** + * Configure the model factory to create a favorite for a user. + */ + public function forUser(): self + { + return $this->state(function () { + $user = User::factory()->create(); + + return [ + 'post_id' => null, + 'favoritable_type' => User::class, + 'favoritable_id' => $user->id, + ]; + }); + } } diff --git a/database/migrations/2026_01_09_015633_add_polymorphic_relationship_to_favorites_table.php b/database/migrations/2026_01_09_015633_add_polymorphic_relationship_to_favorites_table.php new file mode 100644 index 0000000..90039de --- /dev/null +++ b/database/migrations/2026_01_09_015633_add_polymorphic_relationship_to_favorites_table.php @@ -0,0 +1,37 @@ +nullableMorphs('favoritable'); + $table->unsignedBigInteger('post_id')->nullable()->change(); + }); + + // Migrate existing data + DB::table('favorites')->update([ + 'favoritable_type' => 'App\\Models\\Post', + 'favoritable_id' => DB::raw('post_id') + ]); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('favorites', function (Blueprint $table) { + $table->unsignedBigInteger('post_id')->nullable(false)->change(); + $table->dropMorphs('favoritable'); + }); + } +}; diff --git a/database/migrations/2026_01_09_034901_add_image_to_posts_table.php b/database/migrations/2026_01_09_034901_add_image_to_posts_table.php new file mode 100644 index 0000000..0b75848 --- /dev/null +++ b/database/migrations/2026_01_09_034901_add_image_to_posts_table.php @@ -0,0 +1,28 @@ +string('image_path')->nullable()->after('body'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('posts', function (Blueprint $table) { + $table->dropColumn('image_path'); + }); + } +}; diff --git a/database/seeders/FavoriteSeeder.php b/database/seeders/FavoriteSeeder.php index 9ccffd0..b57905d 100644 --- a/database/seeders/FavoriteSeeder.php +++ b/database/seeders/FavoriteSeeder.php @@ -3,6 +3,7 @@ namespace Database\Seeders; use App\Models\Favorite; +use App\Models\Post; use Illuminate\Database\Seeder; class FavoriteSeeder extends Seeder @@ -14,6 +15,14 @@ public function run(): void { Favorite::factory() ->count(5) + ->state(function () { + return [ + 'favoritable_type' => Post::class, + 'favoritable_id' => function (array $attributes) { + return $attributes['post_id']; + } + ]; + }) ->create(); } } diff --git a/routes/api.php b/routes/api.php index 5809cc1..5b721ec 100644 --- a/routes/api.php +++ b/routes/api.php @@ -29,6 +29,12 @@ Route::post('logout', LogoutController::class)->name('logout'); Route::apiResource('posts', PostController::class, ['except' => ['index']]); Route::get('favorites', [FavoriteController::class, 'index'])->name('favorites.index'); - Route::post('posts/{post}/favorite', [FavoriteController::class, 'store'])->name('favorites.store'); - Route::delete('posts/{post}/favorite', [FavoriteController::class, 'destroy'])->name('favorites.destroy'); + + // Post favorites routes + Route::post('posts/{post}/favorite', [FavoriteController::class, 'storePost'])->name('favorites.posts.store'); + Route::delete('posts/{post}/favorite', [FavoriteController::class, 'destroyPost'])->name('favorites.posts.destroy'); + + // User favorites routes + Route::post('users/{user}/favorite', [FavoriteController::class, 'storeUser'])->name('favorites.users.store'); + Route::delete('users/{user}/favorite', [FavoriteController::class, 'destroyUser'])->name('favorites.users.destroy'); }); diff --git a/tests/Feature/FavoriteTest.php b/tests/Feature/FavoriteTest.php index b27dc39..f94abf2 100644 --- a/tests/Feature/FavoriteTest.php +++ b/tests/Feature/FavoriteTest.php @@ -4,18 +4,27 @@ use App\Models\User; use App\Models\Post; -use Illuminate\Foundation\Testing\DatabaseMigrations; +use App\Models\Favorite; +use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; class FavoriteTest extends TestCase { - use DatabaseMigrations; + use RefreshDatabase; + + protected function setUp(): void + { + parent::setUp(); + + // Create the favorites table with the polymorphic relationship for testing + $this->artisan('migrate:fresh'); + } public function test_a_guest_can_not_favorite_a_post() { $post = Post::factory()->create(); - $this->postJson(route('favorites.store', ['post' => $post])) + $this->postJson(route('favorites.posts.store', ['post' => $post])) ->assertStatus(401); } @@ -25,12 +34,13 @@ public function test_a_user_can_favorite_a_post() $post = Post::factory()->create(); $this->actingAs($user) - ->postJson(route('favorites.store', ['post' => $post])) + ->postJson(route('favorites.posts.store', ['post' => $post])) ->assertCreated(); $this->assertDatabaseHas('favorites', [ - 'post_id' => $post->id, 'user_id' => $user->id, + 'favoritable_type' => 'App\\Models\\Post', + 'favoritable_id' => $post->id, ]); } @@ -39,32 +49,162 @@ public function test_a_user_can_remove_a_post_from_his_favorites() $user = User::factory()->create(); $post = Post::factory()->create(); + // Create favorite directly + Favorite::create([ + 'user_id' => $user->id, + 'favoritable_type' => 'App\\Models\\Post', + 'favoritable_id' => $post->id, + ]); + + $this->actingAs($user) + ->deleteJson(route('favorites.posts.destroy', ['post' => $post])) + ->assertNoContent(); + + $this->assertDatabaseMissing('favorites', [ + 'user_id' => $user->id, + 'favoritable_type' => 'App\\Models\\Post', + 'favoritable_id' => $post->id, + ]); + } + + public function test_a_user_can_not_remove_a_non_favorited_item() + { + $user = User::factory()->create(); + $post = Post::factory()->create(); + + $this->actingAs($user) + ->deleteJson(route('favorites.posts.destroy', ['post' => $post])) + ->assertNotFound(); + } + + // User favorites tests + + public function test_a_guest_can_not_favorite_a_user() + { + $userToFavorite = User::factory()->create(); + + $this->postJson(route('favorites.users.store', ['user' => $userToFavorite])) + ->assertStatus(401); + } + + public function test_a_user_can_favorite_another_user() + { + $user = User::factory()->create(); + $userToFavorite = User::factory()->create(); + $this->actingAs($user) - ->postJson(route('favorites.store', ['post' => $post])) + ->postJson(route('favorites.users.store', ['user' => $userToFavorite])) ->assertCreated(); $this->assertDatabaseHas('favorites', [ - 'post_id' => $post->id, 'user_id' => $user->id, + 'favoritable_type' => 'App\\Models\\User', + 'favoritable_id' => $userToFavorite->id, + ]); + } + + public function test_a_user_cannot_favorite_themselves() + { + $user = User::factory()->create(); + + $this->actingAs($user) + ->postJson(route('favorites.users.store', ['user' => $user])) + ->assertStatus(400) + ->assertJsonFragment(['message' => 'You cannot favorite yourself']); + } + + public function test_a_user_can_remove_another_user_from_favorites() + { + $user = User::factory()->create(); + $userToFavorite = User::factory()->create(); + + // Create favorite directly + Favorite::create([ + 'user_id' => $user->id, + 'favoritable_type' => 'App\\Models\\User', + 'favoritable_id' => $userToFavorite->id, ]); $this->actingAs($user) - ->deleteJson(route('favorites.destroy', ['post' => $post])) + ->deleteJson(route('favorites.users.destroy', ['user' => $userToFavorite])) ->assertNoContent(); $this->assertDatabaseMissing('favorites', [ - 'post_id' => $post->id, 'user_id' => $user->id, + 'favoritable_type' => 'App\\Models\\User', + 'favoritable_id' => $userToFavorite->id, ]); } - - public function test_a_user_can_not_remove_a_non_favorited_item() + + public function test_a_user_can_not_remove_a_non_favorited_user() { $user = User::factory()->create(); - $post = Post::factory()->create(); + $userToUnfavorite = User::factory()->create(); $this->actingAs($user) - ->deleteJson(route('favorites.destroy', ['post' => $post])) + ->deleteJson(route('favorites.users.destroy', ['user' => $userToUnfavorite])) ->assertNotFound(); } + + public function test_favorites_index_returns_posts_and_users() + { + $user = User::factory()->create(); + $post1 = Post::factory()->create(['title' => 'All about cats']); + $post2 = Post::factory()->create(['title' => 'All about dogs']); + $userToFavorite1 = User::factory()->create(['name' => 'Jack']); + $userToFavorite2 = User::factory()->create(['name' => 'Jane']); + + // Create favorites + Favorite::create([ + 'user_id' => $user->id, + 'favoritable_type' => 'App\\Models\\Post', + 'favoritable_id' => $post1->id, + ]); + + Favorite::create([ + 'user_id' => $user->id, + 'favoritable_type' => 'App\\Models\\Post', + 'favoritable_id' => $post2->id, + ]); + + Favorite::create([ + 'user_id' => $user->id, + 'favoritable_type' => 'App\\Models\\User', + 'favoritable_id' => $userToFavorite1->id, + ]); + + Favorite::create([ + 'user_id' => $user->id, + 'favoritable_type' => 'App\\Models\\User', + 'favoritable_id' => $userToFavorite2->id, + ]); + + $response = $this->actingAs($user)->getJson(route('favorites.index')); + + $response->assertStatus(200) + ->assertJsonStructure([ + 'data' => [ + 'posts' => [ + '*' => [ + 'id', + 'title', + 'body', + 'user', + ] + ], + 'users' => [ + '*' => [ + 'id', + 'name', + ] + ] + ] + ]) + ->assertJsonCount(2, 'data.posts') + ->assertJsonCount(2, 'data.users') + ->assertJsonFragment(['title' => 'All about cats']) + ->assertJsonFragment(['title' => 'All about dogs']) + ->assertJsonFragment(['name' => 'Jack']) + ->assertJsonFragment(['name' => 'Jane']); + } } diff --git a/tests/Feature/ImportUsersFromApiTest.php b/tests/Feature/ImportUsersFromApiTest.php new file mode 100644 index 0000000..7f47657 --- /dev/null +++ b/tests/Feature/ImportUsersFromApiTest.php @@ -0,0 +1,186 @@ + Http::response([ + [ + 'id' => 1, + 'name' => 'Leanne Graham', + 'username' => 'Bret', + 'email' => 'Sincere@april.biz', + 'address' => [ + 'street' => 'Kulas Light', + 'suite' => 'Apt. 556', + 'city' => 'Gwenborough', + 'zipcode' => '92998-3874', + 'geo' => [ + 'lat' => '-37.3159', + 'lng' => '81.1496' + ] + ], + 'phone' => '1-770-736-8031 x56442', + 'website' => 'hildegard.org', + 'company' => [ + 'name' => 'Romaguera-Crona', + 'catchPhrase' => 'Multi-layered client-server neural-net', + 'bs' => 'harness real-time e-markets' + ] + ], + [ + 'id' => 2, + 'name' => 'Ervin Howell', + 'username' => 'Antonette', + 'email' => 'Shanna@melissa.tv', + 'address' => [ + 'street' => 'Victor Plains', + 'suite' => 'Suite 879', + 'city' => 'Wisokyburgh', + 'zipcode' => '90566-7771', + 'geo' => [ + 'lat' => '-43.9509', + 'lng' => '-34.4618' + ] + ], + 'phone' => '010-692-6593 x09125', + 'website' => 'anastasia.net', + 'company' => [ + 'name' => 'Deckow-Crist', + 'catchPhrase' => 'Proactive didactic contingency', + 'bs' => 'synergize scalable supply-chains' + ] + ] + ], 200) + ]); + + // Run the command + $this->artisan('users:import-from-api') + ->expectsOutput('Importing users from https://jsonplaceholder.typicode.com/users') + ->expectsOutput('Found 2 users to import.') + ->assertExitCode(0); + + // Assert users were created + $this->assertDatabaseHas('users', [ + 'name' => 'Leanne Graham', + 'email' => 'Sincere@april.biz', + ]); + + $this->assertDatabaseHas('users', [ + 'name' => 'Ervin Howell', + 'email' => 'Shanna@melissa.tv', + ]); + } + + public function test_command_skips_existing_users() + { + // Create a user that already exists + User::factory()->create([ + 'name' => 'Leanne Graham', + 'email' => 'Sincere@april.biz', + ]); + + // Mock the HTTP response + Http::fake([ + 'https://jsonplaceholder.typicode.com/users' => Http::response([ + [ + 'id' => 1, + 'name' => 'Leanne Graham', + 'email' => 'Sincere@april.biz', + ], + [ + 'id' => 2, + 'name' => 'Ervin Howell', + 'email' => 'Shanna@melissa.tv', + ] + ], 200) + ]); + + // Run the command + $this->artisan('users:import-from-api') + ->expectsOutput('Importing users from https://jsonplaceholder.typicode.com/users') + ->expectsOutput('Found 2 users to import.') + ->assertExitCode(0); + + // Assert only one new user was created (the other was skipped) + $this->assertEquals(2, User::count()); + $this->assertDatabaseHas('users', [ + 'name' => 'Ervin Howell', + 'email' => 'Shanna@melissa.tv', + ]); + } + + public function test_command_handles_api_error() + { + // Mock a failed HTTP response + Http::fake([ + 'https://jsonplaceholder.typicode.com/users' => Http::response(null, 500) + ]); + + // Run the command + $this->artisan('users:import-from-api') + ->expectsOutput('Importing users from https://jsonplaceholder.typicode.com/users') + ->expectsOutput('Failed to fetch users from the API: 500') + ->assertExitCode(1); + + // Assert no users were created + $this->assertEquals(0, User::count()); + } + + public function test_command_respects_limit_option() + { + // Mock the HTTP response + Http::fake([ + 'https://jsonplaceholder.typicode.com/users' => Http::response([ + [ + 'id' => 1, + 'name' => 'Leanne Graham', + 'email' => 'Sincere@april.biz', + ], + [ + 'id' => 2, + 'name' => 'Ervin Howell', + 'email' => 'Shanna@melissa.tv', + ], + [ + 'id' => 3, + 'name' => 'Clementine Bauch', + 'email' => 'Nathan@yesenia.net', + ] + ], 200) + ]); + + // Run the command with limit option + $this->artisan('users:import-from-api --limit=2') + ->expectsOutput('Importing users from https://jsonplaceholder.typicode.com/users') + ->expectsOutput('Limiting import to 2 users.') + ->expectsOutput('Found 2 users to import.') + ->assertExitCode(0); + + // Assert only 2 users were created + $this->assertEquals(2, User::count()); + $this->assertDatabaseHas('users', [ + 'name' => 'Leanne Graham', + 'email' => 'Sincere@april.biz', + ]); + $this->assertDatabaseHas('users', [ + 'name' => 'Ervin Howell', + 'email' => 'Shanna@melissa.tv', + ]); + $this->assertDatabaseMissing('users', [ + 'name' => 'Clementine Bauch', + 'email' => 'Nathan@yesenia.net', + ]); + } +} diff --git a/tests/Feature/NotificationTest.php b/tests/Feature/NotificationTest.php new file mode 100644 index 0000000..48a9925 --- /dev/null +++ b/tests/Feature/NotificationTest.php @@ -0,0 +1,92 @@ +create(); + $follower1 = User::factory()->create(); + $follower2 = User::factory()->create(); + $nonFollower = User::factory()->create(); + + // Create favorites (followers) + Favorite::create([ + 'user_id' => $follower1->id, + 'favoritable_type' => User::class, + 'favoritable_id' => $author->id, + ]); + + Favorite::create([ + 'user_id' => $follower2->id, + 'favoritable_type' => User::class, + 'favoritable_id' => $author->id, + ]); + + // Author creates a post + $postData = [ + 'title' => 'Test Post Title', + 'body' => 'Test post body content', + ]; + + $this->actingAs($author) + ->postJson(route('posts.store'), $postData) + ->assertStatus(201); + + // Assert notifications were sent to followers + Notification::assertSentTo( + [$follower1, $follower2], + NewPostByFavoritedUser::class, + function ($notification, $channels, $notifiable) use ($author) { + return $notification->getAuthor()->id === $author->id; + } + ); + + // Assert notification was not sent to non-follower + Notification::assertNotSentTo( + [$nonFollower], + NewPostByFavoritedUser::class + ); + } + + public function test_notification_has_correct_content() + { + // Create users + $author = User::factory()->create(['name' => 'Author Name']); + $follower = User::factory()->create(['name' => 'Follower Name']); + + // Create post + $post = Post::factory()->create([ + 'title' => 'Test Post Title', + 'body' => 'Test post body content', + 'user_id' => $author->id, + ]); + + // Create notification instance + $notification = new NewPostByFavoritedUser($post, $author); + + // Get mail message + $mail = $notification->toMail($follower); + + // Assert mail content + $this->assertEquals("{$author->name} has published a new post", $mail->subject); + $this->assertStringContainsString("Hello {$follower->name}!", $mail->greeting); + $this->assertStringContainsString("{$author->name} has published a new post:", $mail->introLines[0]); + $this->assertStringContainsString("Title: {$post->title}", $mail->introLines[1]); + $this->assertStringContainsString("Content: {$post->body}", $mail->introLines[2]); + } +} diff --git a/tests/Feature/PostImageTest.php b/tests/Feature/PostImageTest.php new file mode 100644 index 0000000..5037ce0 --- /dev/null +++ b/tests/Feature/PostImageTest.php @@ -0,0 +1,134 @@ +create(); + $image = UploadedFile::fake()->image('post-image.jpg', 1200, 800); + + $response = $this->actingAs($user) + ->postJson('/api/posts', [ + 'title' => 'Test Post with Image', + 'body' => 'This is a test post with an image attachment.', + 'image' => $image, + ]); + + $response->assertStatus(201) + ->assertJsonStructure([ + 'data' => [ + 'id', + 'title', + 'body', + 'image_url', + 'user', + ] + ]) + ->assertJsonFragment([ + 'title' => 'Test Post with Image', + 'body' => 'This is a test post with an image attachment.', + ]); + + // Assert the image was stored + $post = Post::first(); + $this->assertNotNull($post->image_path); + Storage::disk('public')->assertExists($post->image_path); + } + + public function test_user_can_create_post_without_image() + { + $user = User::factory()->create(); + + $response = $this->actingAs($user) + ->postJson('/api/posts', [ + 'title' => 'Test Post without Image', + 'body' => 'This is a test post without an image attachment.', + ]); + + $response->assertStatus(201) + ->assertJsonStructure([ + 'data' => [ + 'id', + 'title', + 'body', + 'image_url', + 'user', + ] + ]) + ->assertJsonFragment([ + 'title' => 'Test Post without Image', + 'body' => 'This is a test post without an image attachment.', + 'image_url' => null, + ]); + + // Assert no image was stored + $post = Post::first(); + $this->assertNull($post->image_path); + } + + public function test_user_can_update_post_with_new_image() + { + $user = User::factory()->create(); + $post = Post::factory()->create([ + 'user_id' => $user->id, + 'title' => 'Original Post', + 'body' => 'Original content', + ]); + + $image = UploadedFile::fake()->image('updated-image.png', 800, 600); + + $response = $this->actingAs($user) + ->putJson("/api/posts/{$post->id}", [ + 'title' => 'Updated Post', + 'body' => 'Updated content', + 'image' => $image, + ]); + + $response->assertStatus(200) + ->assertJsonFragment([ + 'title' => 'Updated Post', + 'body' => 'Updated content', + ]); + + // Assert the image was stored + $updatedPost = Post::find($post->id); + $this->assertNotNull($updatedPost->image_path); + Storage::disk('public')->assertExists($updatedPost->image_path); + } + + public function test_invalid_image_upload_is_rejected() + { + $user = User::factory()->create(); + $invalidFile = UploadedFile::fake()->create('document.pdf', 1000, 'application/pdf'); + + $response = $this->actingAs($user) + ->postJson('/api/posts', [ + 'title' => 'Test Post with Invalid File', + 'body' => 'This is a test post with an invalid file attachment.', + 'image' => $invalidFile, + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['image']); + + // Assert no post was created + $this->assertEquals(0, Post::count()); + } +}