From 2e1ce0d93bc093576cab4df85d69c092ae37a81c Mon Sep 17 00:00:00 2001 From: DoarMihai Date: Fri, 6 Feb 2026 14:56:56 +0200 Subject: [PATCH] product submission --- app/Http/Controllers/ProductController.php | 198 +++++++++ app/Models/Category.php | 28 ++ app/Models/Product.php | 200 +++++++++ app/Models/User.php | 9 + ...6_02_06_124111_create_categories_table.php | 30 ++ ...026_02_06_124147_create_products_table.php | 41 ++ database/seeders/CategorySeeder.php | 70 +++ database/seeders/DatabaseSeeder.php | 1 + resources/views/products/create.blade.php | 149 +++++++ resources/views/products/edit.blade.php | 152 +++++++ resources/views/products/index.blade.php | 78 ++++ resources/views/products/manage.blade.php | 168 +++++++ resources/views/products/show.blade.php | 114 +++++ routes/web.php | 20 + tests/Feature/ProductTest.php | 415 ++++++++++++++++++ 15 files changed, 1673 insertions(+) create mode 100644 app/Http/Controllers/ProductController.php create mode 100644 app/Models/Category.php create mode 100644 app/Models/Product.php create mode 100644 database/migrations/2026_02_06_124111_create_categories_table.php create mode 100644 database/migrations/2026_02_06_124147_create_products_table.php create mode 100644 database/seeders/CategorySeeder.php create mode 100644 resources/views/products/create.blade.php create mode 100644 resources/views/products/edit.blade.php create mode 100644 resources/views/products/index.blade.php create mode 100644 resources/views/products/manage.blade.php create mode 100644 resources/views/products/show.blade.php create mode 100644 tests/Feature/ProductTest.php diff --git a/app/Http/Controllers/ProductController.php b/app/Http/Controllers/ProductController.php new file mode 100644 index 0000000..9237317 --- /dev/null +++ b/app/Http/Controllers/ProductController.php @@ -0,0 +1,198 @@ +approved() + ->latest(); + + if ($request->has('category')) { + $query->where('category_id', $request->category); + } + + $products = $query->paginate(12); + $categories = Category::all(); + + return view('products.index', compact('products', 'categories')); + } + + /** + * Show the form for creating a new product. + */ + public function create() + { + $categories = Category::all(); + return view('products.create', compact('categories')); + } + + /** + * Store a newly created product in storage. + */ + public function store(Request $request) + { + Gate::authorize('create-product'); + + $validated = $request->validate([ + 'title' => 'required|string|max:255', + 'description' => 'required|string', + 'website_url' => 'required|url', + 'thumbnail' => 'nullable|image|mimes:jpeg,png,jpg,gif|max:2048', + 'category_id' => 'required|exists:categories,id', + ]); + + $validated['user_id'] = Auth::id(); + $validated['status'] = 'pending'; + + // Handle thumbnail upload + if ($request->hasFile('thumbnail')) { + $path = $request->file('thumbnail')->store('thumbnails', 'public'); + $validated['thumbnail'] = $path; + } + + $product = Product::create($validated); + + return redirect()->route('products.show', $product) + ->with('success', 'Product submitted successfully! It will be reviewed by our moderators.'); + } + + /** + * Display the specified product. + */ + public function show(Product $product) + { + // Only show approved products to non-owners + if (!$product->isApproved() && $product->user_id !== Auth::id() && !Auth::user()?->hasAnyRole(['admin', 'moderator'])) { + abort(404); + } + + $product->load(['user', 'category']); + + return view('products.show', compact('product')); + } + + /** + * Show the form for editing the specified product. + */ + public function edit(Product $product) + { + Gate::authorize('edit-product', $product); + + $categories = Category::all(); + return view('products.edit', compact('product', 'categories')); + } + + /** + * Update the specified product in storage. + */ + public function update(Request $request, Product $product) + { + Gate::authorize('edit-product', $product); + + $validated = $request->validate([ + 'title' => 'required|string|max:255', + 'description' => 'required|string', + 'website_url' => 'required|url', + 'thumbnail' => 'nullable|image|mimes:jpeg,png,jpg,gif|max:2048', + 'category_id' => 'required|exists:categories,id', + ]); + + // Handle thumbnail upload + if ($request->hasFile('thumbnail')) { + // Delete old thumbnail if exists + if ($product->thumbnail) { + Storage::disk('public')->delete($product->thumbnail); + } + + $path = $request->file('thumbnail')->store('thumbnails', 'public'); + $validated['thumbnail'] = $path; + } + + $product->update($validated); + + return redirect()->route('products.show', $product) + ->with('success', 'Product updated successfully!'); + } + + /** + * Remove the specified product from storage. + */ + public function destroy(Product $product) + { + Gate::authorize('delete-product', $product); + + // Delete thumbnail if exists + if ($product->thumbnail) { + Storage::disk('public')->delete($product->thumbnail); + } + + $product->delete(); + + return redirect()->route('products.index') + ->with('success', 'Product deleted successfully!'); + } + + /** + * Show the product management dashboard for admin/moderator. + */ + public function manage(Request $request) + { + Gate::authorize('approve-product'); + + $query = Product::with(['user', 'category']); + + // Filter by status + if ($request->has('status') && in_array($request->status, ['pending', 'approved', 'rejected'])) { + $query->byStatus($request->status); + } else { + $query->pending(); // Default to pending + } + + $products = $query->latest()->paginate(20); + + $statusCounts = [ + 'pending' => Product::pending()->count(), + 'approved' => Product::approved()->count(), + 'rejected' => Product::rejected()->count(), + ]; + + return view('products.manage', compact('products', 'statusCounts')); + } + + /** + * Approve a product. + */ + public function approve(Product $product) + { + Gate::authorize('approve-product'); + + $product->approve(); + + return back()->with('success', 'Product approved successfully!'); + } + + /** + * Reject a product. + */ + public function reject(Product $product) + { + Gate::authorize('reject-product'); + + $product->reject(); + + return back()->with('success', 'Product rejected.'); + } +} diff --git a/app/Models/Category.php b/app/Models/Category.php new file mode 100644 index 0000000..0bbfea0 --- /dev/null +++ b/app/Models/Category.php @@ -0,0 +1,28 @@ + + */ + protected $fillable = [ + 'name', + 'slug', + 'description', + ]; + + /** + * Get the products for the category. + */ + public function products(): HasMany + { + return $this->hasMany(Product::class); + } +} diff --git a/app/Models/Product.php b/app/Models/Product.php new file mode 100644 index 0000000..cc1f8ca --- /dev/null +++ b/app/Models/Product.php @@ -0,0 +1,200 @@ + + */ + protected $fillable = [ + 'title', + 'slug', + 'description', + 'website_url', + 'thumbnail', + 'user_id', + 'category_id', + 'status', + 'approved_at', + 'rejected_at', + ]; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'approved_at' => 'datetime', + 'rejected_at' => 'datetime', + ]; + } + + /** + * Boot the model. + */ + protected static function boot() + { + parent::boot(); + + static::creating(function ($product) { + if (empty($product->slug)) { + $product->slug = static::generateUniqueSlug($product->title); + } + }); + + static::updating(function ($product) { + if ($product->isDirty('title')) { + $product->slug = static::generateUniqueSlug($product->title, $product->id); + } + }); + } + + /** + * Generate a unique slug from the title. + */ + protected static function generateUniqueSlug(string $title, ?int $ignoreId = null): string + { + $slug = Str::slug($title); + $originalSlug = $slug; + $counter = 1; + + while (static::slugExists($slug, $ignoreId)) { + $slug = $originalSlug . '-' . $counter; + $counter++; + } + + return $slug; + } + + /** + * Check if a slug exists. + */ + protected static function slugExists(string $slug, ?int $ignoreId = null): bool + { + $query = static::where('slug', $slug); + + if ($ignoreId) { + $query->where('id', '!=', $ignoreId); + } + + return $query->exists(); + } + + /** + * Get the user that owns the product. + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * Get the category that owns the product. + */ + public function category(): BelongsTo + { + return $this->belongsTo(Category::class); + } + + /** + * Scope a query to only include approved products. + */ + public function scopeApproved(Builder $query): Builder + { + return $query->where('status', 'approved'); + } + + /** + * Scope a query to only include pending products. + */ + public function scopePending(Builder $query): Builder + { + return $query->where('status', 'pending'); + } + + /** + * Scope a query to only include rejected products. + */ + public function scopeRejected(Builder $query): Builder + { + return $query->where('status', 'rejected'); + } + + /** + * Scope a query to filter by status. + */ + public function scopeByStatus(Builder $query, string $status): Builder + { + return $query->where('status', $status); + } + + /** + * Get the product's thumbnail URL. + */ + public function getThumbnailUrlAttribute(): ?string + { + if ($this->thumbnail) { + return asset('storage/' . $this->thumbnail); + } + return null; + } + + /** + * Check if the product is pending. + */ + public function isPending(): bool + { + return $this->status === 'pending'; + } + + /** + * Check if the product is approved. + */ + public function isApproved(): bool + { + return $this->status === 'approved'; + } + + /** + * Check if the product is rejected. + */ + public function isRejected(): bool + { + return $this->status === 'rejected'; + } + + /** + * Approve the product. + */ + public function approve(): void + { + $this->update([ + 'status' => 'approved', + 'approved_at' => now(), + 'rejected_at' => null, + ]); + } + + /** + * Reject the product. + */ + public function reject(): void + { + $this->update([ + 'status' => 'rejected', + 'rejected_at' => now(), + 'approved_at' => null, + ]); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 96513d7..834ef29 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -5,6 +5,7 @@ namespace App\Models; // use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; @@ -111,4 +112,12 @@ class User extends Authenticatable } return null; } + + /** + * Get the products for the user. + */ + public function products(): HasMany + { + return $this->hasMany(Product::class); + } } diff --git a/database/migrations/2026_02_06_124111_create_categories_table.php b/database/migrations/2026_02_06_124111_create_categories_table.php new file mode 100644 index 0000000..cc54655 --- /dev/null +++ b/database/migrations/2026_02_06_124111_create_categories_table.php @@ -0,0 +1,30 @@ +id(); + $table->string('name'); + $table->string('slug')->unique(); + $table->text('description')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('categories'); + } +}; diff --git a/database/migrations/2026_02_06_124147_create_products_table.php b/database/migrations/2026_02_06_124147_create_products_table.php new file mode 100644 index 0000000..423ed0c --- /dev/null +++ b/database/migrations/2026_02_06_124147_create_products_table.php @@ -0,0 +1,41 @@ +id(); + $table->string('title'); + $table->string('slug')->unique(); + $table->text('description'); + $table->string('website_url'); + $table->string('thumbnail')->nullable(); + $table->foreignId('user_id')->constrained()->onDelete('cascade'); + $table->foreignId('category_id')->constrained()->onDelete('cascade'); + $table->string('status')->default('pending'); + $table->timestamp('approved_at')->nullable(); + $table->timestamp('rejected_at')->nullable(); + $table->timestamps(); + + $table->index('status'); + $table->index('user_id'); + $table->index('category_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('products'); + } +}; diff --git a/database/seeders/CategorySeeder.php b/database/seeders/CategorySeeder.php new file mode 100644 index 0000000..cf1f0bb --- /dev/null +++ b/database/seeders/CategorySeeder.php @@ -0,0 +1,70 @@ + 'Developer Tools', + 'description' => 'Tools and services for developers', + ], + [ + 'name' => 'Productivity', + 'description' => 'Apps and tools to boost productivity', + ], + [ + 'name' => 'Design', + 'description' => 'Design tools and resources', + ], + [ + 'name' => 'Marketing', + 'description' => 'Marketing tools and platforms', + ], + [ + 'name' => 'AI & Machine Learning', + 'description' => 'AI-powered tools and services', + ], + [ + 'name' => 'Analytics', + 'description' => 'Data analytics and tracking tools', + ], + [ + 'name' => 'Communication', + 'description' => 'Communication and collaboration tools', + ], + [ + 'name' => 'E-commerce', + 'description' => 'E-commerce platforms and tools', + ], + [ + 'name' => 'Education', + 'description' => 'Educational tools and platforms', + ], + [ + 'name' => 'Finance', + 'description' => 'Financial services and tools', + ], + ]; + + foreach ($categories as $category) { + Category::create([ + 'name' => $category['name'], + 'slug' => Str::slug($category['name']), + 'description' => $category['description'], + ]); + } + + $this->command->info('Categories created successfully!'); + } +} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 65f5621..5f5dfa8 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -17,6 +17,7 @@ class DatabaseSeeder extends Seeder { $this->call([ RoleSeeder::class, + CategorySeeder::class, ]); // Uncomment to create additional users with factory diff --git a/resources/views/products/create.blade.php b/resources/views/products/create.blade.php new file mode 100644 index 0000000..844a9e3 --- /dev/null +++ b/resources/views/products/create.blade.php @@ -0,0 +1,149 @@ +@extends('layouts.app') + +@section('content') +
+
+
+
+
+

Submit a Product

+
+ +
+
+ @csrf + + +
+ + + @error('title') + + {{ $message }} + + @enderror + A catchy, descriptive title for your product. +
+ + +
+ + + @error('description') + + {{ $message }} + + @enderror + Provide a detailed description of your product (what it does, key features, etc.). +
+ + +
+ + + @error('website_url') + + {{ $message }} + + @enderror + The full URL to your product's website or landing page. +
+ + +
+ + + @error('category_id') + + {{ $message }} + + @enderror + Choose the category that best fits your product. +
+ + +
+ + + @error('thumbnail') + + {{ $message }} + + @enderror + Upload a product screenshot or logo (JPEG, PNG, or GIF, max 2MB). +
+ + + + + +
+ Cancel + +
+ + +
+
+
+
+
+
+ +@push('scripts') + +@endpush +@endsection diff --git a/resources/views/products/edit.blade.php b/resources/views/products/edit.blade.php new file mode 100644 index 0000000..60e8b60 --- /dev/null +++ b/resources/views/products/edit.blade.php @@ -0,0 +1,152 @@ +@extends('layouts.app') + +@section('content') +
+
+
+
+
+

Edit Product

+
+ +
+
+ @csrf + @method('PUT') + + +
+ + + @error('title') + + {{ $message }} + + @enderror +
+ + +
+ + + @error('description') + + {{ $message }} + + @enderror +
+ + +
+ + + @error('website_url') + + {{ $message }} + + @enderror +
+ + +
+ + + @error('category_id') + + {{ $message }} + + @enderror +
+ + + @if($product->thumbnail_url) +
+ +
+ {{ $product->title }} +
+
+ @endif + + +
+ + + @error('thumbnail') + + {{ $message }} + + @enderror + JPEG, PNG, or GIF, max 2MB. +
+ + + + + +
+ Cancel + +
+
+
+
+
+
+
+ +@push('scripts') + +@endpush +@endsection diff --git a/resources/views/products/index.blade.php b/resources/views/products/index.blade.php new file mode 100644 index 0000000..ea466bb --- /dev/null +++ b/resources/views/products/index.blade.php @@ -0,0 +1,78 @@ +@extends('layouts.app') + +@section('content') +
+
+
+
+

Discover Products

+ @auth + Submit a Product + @endauth +
+ + +
+ + All Categories + + @foreach($categories as $category) + + {{ $category->name }} + + @endforeach +
+ + @if(session('success')) + + @endif + + +
+ @forelse($products as $product) +
+
+ @if($product->thumbnail_url) + {{ $product->title }} + @else +
+ No image +
+ @endif +
+
{{ $product->title }}
+

+ {{ $product->category->name }} +

+

{{ Str::limit($product->description, 100) }}

+ +
+ By {{ $product->user->name }} ยท {{ $product->created_at->diffForHumans() }} +
+
+
+
+ @empty +
+
+ No products found. Be the first to submit one! +
+
+ @endforelse +
+ + +
+ {{ $products->links() }} +
+
+
+
+@endsection diff --git a/resources/views/products/manage.blade.php b/resources/views/products/manage.blade.php new file mode 100644 index 0000000..d6c07a1 --- /dev/null +++ b/resources/views/products/manage.blade.php @@ -0,0 +1,168 @@ +@extends('layouts.app') + +@section('content') +
+
+
+

Product Management

+ + @if(session('success')) + + @endif + + + + + +
+
+ @if($products->count() > 0) +
+ + + + + + + + + + + + + + @foreach($products as $product) + + + + + + + + + + @endforeach + +
ThumbnailTitleCategorySubmitted ByStatusDateActions
+ @if($product->thumbnail_url) + {{ $product->title }} + @else +
+ No img +
+ @endif +
+ + {{ $product->title }} + +
+ {{ Str::limit($product->description, 60) }} +
+ {{ $product->category->name }} + + + {{ $product->user->name }} + + + @if($product->isPending()) + Pending + @elseif($product->isApproved()) + Approved + @elseif($product->isRejected()) + Rejected + @endif + + {{ $product->created_at->format('M j, Y') }} +
+ {{ $product->created_at->diffForHumans() }} +
+
+ @if($product->isPending() || $product->isRejected()) +
+ @csrf + +
+ @endif + + @if($product->isPending() || $product->isApproved()) +
+ @csrf + +
+ @endif + + + ๐Ÿ‘ + + + @can('edit-product', $product) + + โœŽ + + @endcan + + @can('delete-product', $product) +
+ @csrf + @method('DELETE') + +
+ @endcan +
+
+
+ + +
+ {{ $products->appends(['status' => request('status')])->links() }} +
+ @else +
+ No products found with status: {{ request('status', 'pending') }} +
+ @endif +
+
+
+
+
+@endsection diff --git a/resources/views/products/show.blade.php b/resources/views/products/show.blade.php new file mode 100644 index 0000000..76e5b83 --- /dev/null +++ b/resources/views/products/show.blade.php @@ -0,0 +1,114 @@ +@extends('layouts.app') + +@section('content') +
+
+
+ @if(session('success')) + + @endif + +
+
+ +
+
+

{{ $product->title }}

+

+ {{ $product->category->name }} + @if($product->isPending()) + Pending Review + @elseif($product->isApproved()) + Approved + @elseif($product->isRejected()) + Rejected + @endif +

+
+
+ @can('edit-product', $product) + Edit + @endcan + @can('delete-product', $product) +
+ @csrf + @method('DELETE') + +
+ @endcan +
+
+ + + @if($product->thumbnail_url) +
+ {{ $product->title }} +
+ @endif + + +
+

About

+

{{ $product->description }}

+
+ + + + + +
+
+
+ Submitted by: + {{ $product->user->name }} +
+
+ Submitted: {{ $product->created_at->format('F j, Y') }} +
+
+ + @if($product->isApproved() && $product->approved_at) +
+
+ Approved: {{ $product->approved_at->format('F j, Y') }} +
+
+ @endif +
+
+ + + @can('approve-product') + @if($product->isPending() || $product->isRejected()) +
+
+
Moderator Actions
+
+
+
+ @csrf + +
+
+ @csrf + +
+
+
+ @endif + @endcan + + +
+
+
+@endsection diff --git a/routes/web.php b/routes/web.php index 19f70df..e2930cb 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,5 +1,6 @@ group(function () { Route::get('/profile/{user}', [ProfileController::class, 'show'])->name('profile.show'); +// Product Routes +Route::get('/products', [ProductController::class, 'index'])->name('products.index'); +Route::get('/products/{product}', [ProductController::class, 'show'])->name('products.show'); + +Route::middleware(['auth'])->group(function () { + Route::get('/products-submit', [ProductController::class, 'create'])->name('products.create'); + Route::post('/products', [ProductController::class, 'store'])->name('products.store'); + Route::get('/products/{product}/edit', [ProductController::class, 'edit'])->name('products.edit'); + Route::put('/products/{product}', [ProductController::class, 'update'])->name('products.update'); + Route::delete('/products/{product}', [ProductController::class, 'destroy'])->name('products.destroy'); +}); + +// Admin/Moderator Product Management Routes +Route::middleware(['auth', 'moderator'])->group(function () { + Route::get('/products-manage', [ProductController::class, 'manage'])->name('products.manage'); + Route::post('/products/{product}/approve', [ProductController::class, 'approve'])->name('products.approve'); + Route::post('/products/{product}/reject', [ProductController::class, 'reject'])->name('products.reject'); +}); + // Admin Routes (example) Route::middleware(['auth', 'admin'])->prefix('admin')->name('admin.')->group(function () { Route::get('/dashboard', function () { diff --git a/tests/Feature/ProductTest.php b/tests/Feature/ProductTest.php new file mode 100644 index 0000000..9dee754 --- /dev/null +++ b/tests/Feature/ProductTest.php @@ -0,0 +1,415 @@ +userRole = Role::create([ + 'name' => 'User', + 'slug' => 'user', + 'description' => 'Regular user', + ]); + + $this->moderatorRole = Role::create([ + 'name' => 'Moderator', + 'slug' => 'moderator', + 'description' => 'Moderator', + ]); + + $this->adminRole = Role::create([ + 'name' => 'Admin', + 'slug' => 'admin', + 'description' => 'Admin', + ]); + + // Create category + $this->category = Category::create([ + 'name' => 'Test Category', + 'slug' => 'test-category', + 'description' => 'Test description', + ]); +}); + +test('guests can view approved products', function () { + $user = User::factory()->create(); + $user->roles()->attach($this->userRole); + + $product = Product::create([ + 'title' => 'Test Product', + 'slug' => 'test-product', + 'description' => 'Test description', + 'website_url' => 'https://example.com', + 'user_id' => $user->id, + 'category_id' => $this->category->id, + 'status' => 'approved', + ]); + + $response = $this->get(route('products.index')); + + $response->assertStatus(200); + $response->assertSee('Test Product'); +}); + +test('guests cannot view pending products', function () { + $user = User::factory()->create(); + $user->roles()->attach($this->userRole); + + $product = Product::create([ + 'title' => 'Pending Product', + 'slug' => 'pending-product', + 'description' => 'Test description', + 'website_url' => 'https://example.com', + 'user_id' => $user->id, + 'category_id' => $this->category->id, + 'status' => 'pending', + ]); + + $response = $this->get(route('products.show', $product)); + + $response->assertStatus(404); +}); + +test('authenticated users can submit products', function () { + $user = User::factory()->create(); + $user->roles()->attach($this->userRole); + + $response = $this->actingAs($user)->post(route('products.store'), [ + 'title' => 'New Product', + 'description' => 'This is a new product', + 'website_url' => 'https://newproduct.com', + 'category_id' => $this->category->id, + ]); + + $response->assertRedirect(); + $this->assertDatabaseHas('products', [ + 'title' => 'New Product', + 'user_id' => $user->id, + 'status' => 'pending', + ]); +}); + +test('guests cannot submit products', function () { + $response = $this->get(route('products.create')); + + $response->assertRedirect(route('login')); +}); + +test('product submission requires title', function () { + $user = User::factory()->create(); + $user->roles()->attach($this->userRole); + + $response = $this->actingAs($user)->post(route('products.store'), [ + 'description' => 'Test description', + 'website_url' => 'https://example.com', + 'category_id' => $this->category->id, + ]); + + $response->assertSessionHasErrors('title'); +}); + +test('product submission requires description', function () { + $user = User::factory()->create(); + $user->roles()->attach($this->userRole); + + $response = $this->actingAs($user)->post(route('products.store'), [ + 'title' => 'Test Product', + 'website_url' => 'https://example.com', + 'category_id' => $this->category->id, + ]); + + $response->assertSessionHasErrors('description'); +}); + +test('product submission requires valid url', function () { + $user = User::factory()->create(); + $user->roles()->attach($this->userRole); + + $response = $this->actingAs($user)->post(route('products.store'), [ + 'title' => 'Test Product', + 'description' => 'Test description', + 'website_url' => 'not-a-valid-url', + 'category_id' => $this->category->id, + ]); + + $response->assertSessionHasErrors('website_url'); +}); + +test('product submission requires valid category', function () { + $user = User::factory()->create(); + $user->roles()->attach($this->userRole); + + $response = $this->actingAs($user)->post(route('products.store'), [ + 'title' => 'Test Product', + 'description' => 'Test description', + 'website_url' => 'https://example.com', + 'category_id' => 999, + ]); + + $response->assertSessionHasErrors('category_id'); +}); + +test('users can upload thumbnail when submitting product', function () { + $user = User::factory()->create(); + $user->roles()->attach($this->userRole); + + $file = UploadedFile::fake()->image('thumbnail.jpg'); + + $response = $this->actingAs($user)->post(route('products.store'), [ + 'title' => 'Test Product', + 'description' => 'Test description', + 'website_url' => 'https://example.com', + 'category_id' => $this->category->id, + 'thumbnail' => $file, + ]); + + $product = Product::where('title', 'Test Product')->first(); + + expect($product->thumbnail)->not->toBeNull(); + Storage::disk('public')->assertExists($product->thumbnail); +}); + +test('thumbnail must be an image', function () { + $user = User::factory()->create(); + $user->roles()->attach($this->userRole); + + $file = UploadedFile::fake()->create('document.pdf'); + + $response = $this->actingAs($user)->post(route('products.store'), [ + 'title' => 'Test Product', + 'description' => 'Test description', + 'website_url' => 'https://example.com', + 'category_id' => $this->category->id, + 'thumbnail' => $file, + ]); + + $response->assertSessionHasErrors('thumbnail'); +}); + +test('product owner can edit their product', function () { + $user = User::factory()->create(); + $user->roles()->attach($this->userRole); + + $product = Product::create([ + 'title' => 'Test Product', + 'slug' => 'test-product', + 'description' => 'Test description', + 'website_url' => 'https://example.com', + 'user_id' => $user->id, + 'category_id' => $this->category->id, + 'status' => 'pending', + ]); + + $response = $this->actingAs($user)->put(route('products.update', $product), [ + 'title' => 'Updated Product', + 'description' => 'Updated description', + 'website_url' => 'https://updated.com', + 'category_id' => $this->category->id, + ]); + + $response->assertRedirect(); + $this->assertDatabaseHas('products', [ + 'id' => $product->id, + 'title' => 'Updated Product', + ]); +}); + +test('non-owner cannot edit product', function () { + $owner = User::factory()->create(); + $owner->roles()->attach($this->userRole); + + $other = User::factory()->create(); + $other->roles()->attach($this->userRole); + + $product = Product::create([ + 'title' => 'Test Product', + 'slug' => 'test-product', + 'description' => 'Test description', + 'website_url' => 'https://example.com', + 'user_id' => $owner->id, + 'category_id' => $this->category->id, + 'status' => 'pending', + ]); + + $response = $this->actingAs($other)->put(route('products.update', $product), [ + 'title' => 'Updated Product', + 'description' => 'Updated description', + 'website_url' => 'https://updated.com', + 'category_id' => $this->category->id, + ]); + + $response->assertStatus(403); +}); + +test('product owner can delete their product', function () { + $user = User::factory()->create(); + $user->roles()->attach($this->userRole); + + $product = Product::create([ + 'title' => 'Test Product', + 'slug' => 'test-product', + 'description' => 'Test description', + 'website_url' => 'https://example.com', + 'user_id' => $user->id, + 'category_id' => $this->category->id, + 'status' => 'pending', + ]); + + $response = $this->actingAs($user)->delete(route('products.destroy', $product)); + + $response->assertRedirect(); + $this->assertDatabaseMissing('products', ['id' => $product->id]); +}); + +test('moderators can approve products', function () { + $moderator = User::factory()->create(); + $moderator->roles()->attach($this->moderatorRole); + + $user = User::factory()->create(); + $user->roles()->attach($this->userRole); + + $product = Product::create([ + 'title' => 'Test Product', + 'slug' => 'test-product', + 'description' => 'Test description', + 'website_url' => 'https://example.com', + 'user_id' => $user->id, + 'category_id' => $this->category->id, + 'status' => 'pending', + ]); + + $response = $this->actingAs($moderator)->post(route('products.approve', $product)); + + $response->assertRedirect(); + $this->assertDatabaseHas('products', [ + 'id' => $product->id, + 'status' => 'approved', + ]); + + expect($product->fresh()->approved_at)->not->toBeNull(); +}); + +test('moderators can reject products', function () { + $moderator = User::factory()->create(); + $moderator->roles()->attach($this->moderatorRole); + + $user = User::factory()->create(); + $user->roles()->attach($this->userRole); + + $product = Product::create([ + 'title' => 'Test Product', + 'slug' => 'test-product', + 'description' => 'Test description', + 'website_url' => 'https://example.com', + 'user_id' => $user->id, + 'category_id' => $this->category->id, + 'status' => 'pending', + ]); + + $response = $this->actingAs($moderator)->post(route('products.reject', $product)); + + $response->assertRedirect(); + $this->assertDatabaseHas('products', [ + 'id' => $product->id, + 'status' => 'rejected', + ]); + + expect($product->fresh()->rejected_at)->not->toBeNull(); +}); + +test('regular users cannot approve products', function () { + $user = User::factory()->create(); + $user->roles()->attach($this->userRole); + + $product = Product::create([ + 'title' => 'Test Product', + 'slug' => 'test-product', + 'description' => 'Test description', + 'website_url' => 'https://example.com', + 'user_id' => $user->id, + 'category_id' => $this->category->id, + 'status' => 'pending', + ]); + + $response = $this->actingAs($user)->post(route('products.approve', $product)); + + $response->assertStatus(403); +}); + +test('regular users cannot access product management page', function () { + $user = User::factory()->create(); + $user->roles()->attach($this->userRole); + + $response = $this->actingAs($user)->get(route('products.manage')); + + $response->assertStatus(403); +}); + +test('moderators can access product management page', function () { + $moderator = User::factory()->create(); + $moderator->roles()->attach($this->moderatorRole); + + $response = $this->actingAs($moderator)->get(route('products.manage')); + + $response->assertStatus(200); +}); + +test('admins can access product management page', function () { + $admin = User::factory()->create(); + $admin->roles()->attach($this->adminRole); + + $response = $this->actingAs($admin)->get(route('products.manage')); + + $response->assertStatus(200); +}); + +test('slug is auto-generated from title', function () { + $user = User::factory()->create(); + $user->roles()->attach($this->userRole); + + $product = Product::create([ + 'title' => 'My Awesome Product', + 'description' => 'Test description', + 'website_url' => 'https://example.com', + 'user_id' => $user->id, + 'category_id' => $this->category->id, + 'status' => 'pending', + ]); + + expect($product->slug)->toBe('my-awesome-product'); +}); + +test('duplicate slugs are handled correctly', function () { + $user = User::factory()->create(); + $user->roles()->attach($this->userRole); + + $product1 = Product::create([ + 'title' => 'Test Product', + 'description' => 'Test description', + 'website_url' => 'https://example.com', + 'user_id' => $user->id, + 'category_id' => $this->category->id, + 'status' => 'pending', + ]); + + $product2 = Product::create([ + 'title' => 'Test Product', + 'description' => 'Test description', + 'website_url' => 'https://example2.com', + 'user_id' => $user->id, + 'category_id' => $this->category->id, + 'status' => 'pending', + ]); + + expect($product1->slug)->toBe('test-product'); + expect($product2->slug)->toBe('test-product-1'); +});