diff --git a/tests/Feature/Admin/Master/ProductTest.php b/tests/Feature/Admin/Master/ProductTest.php new file mode 100644 index 0000000..eb75345 --- /dev/null +++ b/tests/Feature/Admin/Master/ProductTest.php @@ -0,0 +1,647 @@ +seed(RolePermissionSeeder::class); + Storage::fake('public'); +}); + +// ─── Helper ─────────────────────────────────────────────── + +function createProductUserWithPermission(PermissionEnum ...$permissions): User +{ + $user = User::factory()->create(); + + $user->givePermissionTo( + array_merge( + [PermissionEnum::DASHBOARD_VIEW->value], + array_map(fn (PermissionEnum $p) => $p->value, $permissions) + ) + ); + + $user->forgetCachedPermissions(); + + return $user; +} + +function createProductWithVariants(?Category $category = null): Product +{ + $category ??= Category::factory()->create(); + $product = Product::factory()->create(); + $product->categories()->attach($category->id); + + $variants = ProductVariant::factory()->count(2)->create(['product_id' => $product->id]); + + foreach ($variants as $variant) { + $variant->addMedia(UploadedFile::fake()->image("{$variant->name}.jpg", 100, 100)) + ->toMediaCollection('images'); + } + + return $product; +} + +function variantWithImage(string $name = 'All Size', int $stock = 10): array +{ + return [ + 'name' => $name, + 'stock' => $stock, + 'images' => [UploadedFile::fake()->image("{$name}.jpg", 100, 100)], + ]; +} + +// ─── Index ──────────────────────────────────────────────── + +describe('Product Index', function () { + test('authenticated user with permission can view product index', function () { + $user = createProductUserWithPermission(PermissionEnum::PRODUCTS_VIEW); + + createProductWithVariants(); + + $this->actingAs($user) + ->get(route('admin.master.products.index')) + ->assertOk(); + }); + + test('guest is redirected to login', function () { + $this->get(route('admin.master.products.index')) + ->assertRedirect(route('login')); + }); + + test('user without permission is forbidden', function () { + $user = User::factory()->create(); + + $this->actingAs($user) + ->get(route('admin.master.products.index')) + ->assertForbidden(); + }); + + test('index displays products with variants and categories', function () { + $user = createProductUserWithPermission(PermissionEnum::PRODUCTS_VIEW); + + createProductWithVariants(); + createProductWithVariants(); + + $this->actingAs($user) + ->get(route('admin.master.products.index')) + ->assertOk(); + }); + + test('index can search products by name', function () { + $user = createProductUserWithPermission(PermissionEnum::PRODUCTS_VIEW); + + $category = Category::factory()->create(); + $product = Product::factory()->create(['name' => 'Batik Modern']); + $product->categories()->attach($category->id); + ProductVariant::factory()->create(['product_id' => $product->id]); + + $this->actingAs($user) + ->get(route('admin.master.products.index', ['search' => 'Batik'])) + ->assertOk(); + }); + + test('index can filter by active status', function () { + $user = createProductUserWithPermission(PermissionEnum::PRODUCTS_VIEW); + + createProductWithVariants(); + + $this->actingAs($user) + ->get(route('admin.master.products.index', ['is_active' => '1'])) + ->assertOk(); + }); + + test('index can filter by category', function () { + $user = createProductUserWithPermission(PermissionEnum::PRODUCTS_VIEW); + + $category = Category::factory()->create(); + createProductWithVariants($category); + + $this->actingAs($user) + ->get(route('admin.master.products.index', ['category_id' => $category->id])) + ->assertOk(); + }); +}); + +// ─── Create ─────────────────────────────────────────────── + +describe('Product Create', function () { + test('authenticated user with permission can view create form', function () { + $user = createProductUserWithPermission(PermissionEnum::PRODUCTS_VIEW, PermissionEnum::PRODUCTS_CREATE); + + $this->actingAs($user) + ->get(route('admin.master.products.create')) + ->assertOk(); + }); + + test('guest is redirected to login', function () { + $this->get(route('admin.master.products.create')) + ->assertRedirect(route('login')); + }); + + test('user without create permission is forbidden', function () { + $user = createProductUserWithPermission(PermissionEnum::PRODUCTS_VIEW); + + $this->actingAs($user) + ->get(route('admin.master.products.create')) + ->assertForbidden(); + }); +}); + +// ─── Store ──────────────────────────────────────────────── + +describe('Product Store', function () { + test('authenticated user with permission can create a product', function () { + $user = createProductUserWithPermission(PermissionEnum::PRODUCTS_VIEW, PermissionEnum::PRODUCTS_CREATE); + + $category = Category::factory()->create(); + + $this->actingAs($user) + ->post(route('admin.master.products.store'), [ + 'name' => 'Produk Baru', + 'description' => 'Deskripsi produk', + 'category_ids' => [$category->id], + 'variants' => [ + variantWithImage('All Size', 10), + ], + ]) + ->assertRedirect(route('admin.master.products.index')); + + $this->assertDatabaseHas('products', [ + 'name' => 'Produk Baru', + ]); + }); + + test('guest cannot create a product', function () { + $category = Category::factory()->create(); + + $this->post(route('admin.master.products.store'), [ + 'name' => 'Produk Baru', + 'category_ids' => [$category->id], + 'variants' => [variantWithImage()], + ])->assertRedirect(route('login')); + }); + + test('user without create permission is forbidden', function () { + $user = createProductUserWithPermission(PermissionEnum::PRODUCTS_VIEW); + + $category = Category::factory()->create(); + + $this->actingAs($user) + ->post(route('admin.master.products.store'), [ + 'name' => 'Produk Baru', + 'category_ids' => [$category->id], + 'variants' => [variantWithImage()], + ]) + ->assertForbidden(); + }); + + test('name is required', function () { + $user = createProductUserWithPermission(PermissionEnum::PRODUCTS_VIEW, PermissionEnum::PRODUCTS_CREATE); + + $category = Category::factory()->create(); + + $this->actingAs($user) + ->post(route('admin.master.products.store'), [ + 'name' => '', + 'category_ids' => [$category->id], + 'variants' => [variantWithImage()], + ]) + ->assertSessionHasErrors('name'); + }); + + test('name must not exceed 200 characters', function () { + $user = createProductUserWithPermission(PermissionEnum::PRODUCTS_VIEW, PermissionEnum::PRODUCTS_CREATE); + + $category = Category::factory()->create(); + + $this->actingAs($user) + ->post(route('admin.master.products.store'), [ + 'name' => str_repeat('a', 201), + 'category_ids' => [$category->id], + 'variants' => [variantWithImage()], + ]) + ->assertSessionHasErrors('name'); + }); + + test('category_ids is required', function () { + $user = createProductUserWithPermission(PermissionEnum::PRODUCTS_VIEW, PermissionEnum::PRODUCTS_CREATE); + + $this->actingAs($user) + ->post(route('admin.master.products.store'), [ + 'name' => 'Produk Baru', + 'category_ids' => [], + 'variants' => [variantWithImage()], + ]) + ->assertSessionHasErrors('category_ids'); + }); + + test('category must exist', function () { + $user = createProductUserWithPermission(PermissionEnum::PRODUCTS_VIEW, PermissionEnum::PRODUCTS_CREATE); + + $this->actingAs($user) + ->post(route('admin.master.products.store'), [ + 'name' => 'Produk Baru', + 'category_ids' => [99999], + 'variants' => [variantWithImage()], + ]) + ->assertSessionHasErrors('category_ids.0'); + }); + + test('variants is required', function () { + $user = createProductUserWithPermission(PermissionEnum::PRODUCTS_VIEW, PermissionEnum::PRODUCTS_CREATE); + + $category = Category::factory()->create(); + + $this->actingAs($user) + ->post(route('admin.master.products.store'), [ + 'name' => 'Produk Baru', + 'category_ids' => [$category->id], + 'variants' => [], + ]) + ->assertSessionHasErrors('variants'); + }); + + test('variant name is required', function () { + $user = createProductUserWithPermission(PermissionEnum::PRODUCTS_VIEW, PermissionEnum::PRODUCTS_CREATE); + + $category = Category::factory()->create(); + + $this->actingAs($user) + ->post(route('admin.master.products.store'), [ + 'name' => 'Produk Baru', + 'category_ids' => [$category->id], + 'variants' => [variantWithImage('', 10)], + ]) + ->assertSessionHasErrors('variants.0.name'); + }); + + test('variant stock is required', function () { + $user = createProductUserWithPermission(PermissionEnum::PRODUCTS_VIEW, PermissionEnum::PRODUCTS_CREATE); + + $category = Category::factory()->create(); + + $this->actingAs($user) + ->post(route('admin.master.products.store'), [ + 'name' => 'Produk Baru', + 'category_ids' => [$category->id], + 'variants' => [['name' => 'All Size', 'stock' => '', 'images' => [UploadedFile::fake()->image('test.jpg')]]], + ]) + ->assertSessionHasErrors('variants.0.stock'); + }); + + test('variant stock must be non-negative integer', function () { + $user = createProductUserWithPermission(PermissionEnum::PRODUCTS_VIEW, PermissionEnum::PRODUCTS_CREATE); + + $category = Category::factory()->create(); + + $this->actingAs($user) + ->post(route('admin.master.products.store'), [ + 'name' => 'Produk Baru', + 'category_ids' => [$category->id], + 'variants' => [variantWithImage('All Size', -5)], + ]) + ->assertSessionHasErrors('variants.0.stock'); + }); + + test('creating product also creates variants and syncs categories', function () { + $user = createProductUserWithPermission(PermissionEnum::PRODUCTS_VIEW, PermissionEnum::PRODUCTS_CREATE); + + $cat1 = Category::factory()->create(); + $cat2 = Category::factory()->create(); + + $this->actingAs($user) + ->post(route('admin.master.products.store'), [ + 'name' => 'Produk Lengkap', + 'description' => 'Deskripsi lengkap', + 'category_ids' => [$cat1->id, $cat2->id], + 'variants' => [ + variantWithImage('S', 5), + variantWithImage('M', 10), + variantWithImage('L', 15), + ], + ]); + + $product = Product::where('name', 'Produk Lengkap')->first(); + expect($product)->not->toBeNull(); + expect($product->categories)->toHaveCount(2); + expect($product->variants)->toHaveCount(3); + }); + + test('product slug is auto-generated', function () { + $user = createProductUserWithPermission(PermissionEnum::PRODUCTS_VIEW, PermissionEnum::PRODUCTS_CREATE); + + $category = Category::factory()->create(); + + $this->actingAs($user) + ->post(route('admin.master.products.store'), [ + 'name' => 'Batik Modern Elegan', + 'category_ids' => [$category->id], + 'variants' => [variantWithImage()], + ]); + + $this->assertDatabaseHas('products', [ + 'name' => 'Batik Modern Elegan', + 'slug' => 'batik-modern-elegan', + ]); + }); +}); + +// ─── Edit ───────────────────────────────────────────────── + +describe('Product Edit', function () { + test('authenticated user with permission can view edit form', function () { + $user = createProductUserWithPermission(PermissionEnum::PRODUCTS_VIEW, PermissionEnum::PRODUCTS_UPDATE); + + $product = createProductWithVariants(); + + $this->actingAs($user) + ->get(route('admin.master.products.edit', $product)) + ->assertOk(); + }); + + test('guest is redirected to login', function () { + $product = createProductWithVariants(); + + $this->get(route('admin.master.products.edit', $product)) + ->assertRedirect(route('login')); + }); + + test('user without update permission is forbidden', function () { + $user = createProductUserWithPermission(PermissionEnum::PRODUCTS_VIEW); + + $product = createProductWithVariants(); + + $this->actingAs($user) + ->get(route('admin.master.products.edit', $product)) + ->assertForbidden(); + }); +}); + +// ─── Update ─────────────────────────────────────────────── + +describe('Product Update', function () { + test('authenticated user with permission can update a product', function () { + $user = createProductUserWithPermission(PermissionEnum::PRODUCTS_VIEW, PermissionEnum::PRODUCTS_UPDATE); + + $product = createProductWithVariants(); + $category = $product->categories->first(); + $variant = $product->variants->first(); + + $this->actingAs($user) + ->put(route('admin.master.products.update', $product), [ + 'name' => 'Nama Diubah', + 'description' => 'Deskripsi baru', + 'category_ids' => [$category->id], + 'variants' => [ + ['id' => $variant->id, 'name' => 'New Variant', 'stock' => 20], + ], + ]) + ->assertRedirect(route('admin.master.products.index')); + + expect($product->fresh()->name)->toBe('Nama Diubah'); + expect($variant->fresh()->name)->toBe('New Variant'); + }); + + test('guest cannot update a product', function () { + $product = createProductWithVariants(); + $variant = $product->variants->first(); + + $this->put(route('admin.master.products.update', $product), [ + 'name' => 'Nama Baru', + 'category_ids' => [$product->categories->first()->id], + 'variants' => [['id' => $variant->id, 'name' => $variant->name, 'stock' => $variant->stock]], + ])->assertRedirect(route('login')); + }); + + test('user without update permission is forbidden', function () { + $user = createProductUserWithPermission(PermissionEnum::PRODUCTS_VIEW); + + $product = createProductWithVariants(); + $variant = $product->variants->first(); + + $this->actingAs($user) + ->put(route('admin.master.products.update', $product), [ + 'name' => 'Nama Baru', + 'category_ids' => [$product->categories->first()->id], + 'variants' => [['id' => $variant->id, 'name' => $variant->name, 'stock' => $variant->stock]], + ]) + ->assertForbidden(); + }); + + test('name is required on update', function () { + $user = createProductUserWithPermission(PermissionEnum::PRODUCTS_VIEW, PermissionEnum::PRODUCTS_UPDATE); + + $product = createProductWithVariants(); + $category = $product->categories->first(); + $variant = $product->variants->first(); + + $this->actingAs($user) + ->put(route('admin.master.products.update', $product), [ + 'name' => '', + 'category_ids' => [$category->id], + 'variants' => [ + ['id' => $variant->id, 'name' => $variant->name, 'stock' => $variant->stock], + ], + ]) + ->assertSessionHasErrors('name'); + }); + + test('category_ids is required on update', function () { + $user = createProductUserWithPermission(PermissionEnum::PRODUCTS_VIEW, PermissionEnum::PRODUCTS_UPDATE); + + $product = createProductWithVariants(); + $variant = $product->variants->first(); + + $this->actingAs($user) + ->put(route('admin.master.products.update', $product), [ + 'name' => 'Nama Produk', + 'category_ids' => [], + 'variants' => [ + ['id' => $variant->id, 'name' => $variant->name, 'stock' => $variant->stock], + ], + ]) + ->assertSessionHasErrors('category_ids'); + }); + + test('unsubmitted variants are removed on update', function () { + $user = createProductUserWithPermission(PermissionEnum::PRODUCTS_VIEW, PermissionEnum::PRODUCTS_UPDATE); + + $product = createProductWithVariants(); + $category = $product->categories->first(); + $variantToKeep = $product->variants->first(); + + $this->actingAs($user) + ->put(route('admin.master.products.update', $product), [ + 'name' => 'Updated Product', + 'category_ids' => [$category->id], + 'variants' => [ + ['id' => $variantToKeep->id, 'name' => 'Kept Variant', 'stock' => 5], + ], + ]); + + $product->refresh(); + expect($product->variants)->toHaveCount(1); + expect($product->variants->first()->name)->toBe('Kept Variant'); + }); +}); + +// ─── Toggle Status ──────────────────────────────────────── + +describe('Product Toggle Status', function () { + test('authenticated user with permission can toggle product status', function () { + $user = createProductUserWithPermission(PermissionEnum::PRODUCTS_VIEW, PermissionEnum::PRODUCTS_TOGGLE_STATUS); + + $product = createProductWithVariants(); + + $this->actingAs($user) + ->patch(route('admin.master.products.toggle_status', $product), [ + 'is_active' => false, + ]) + ->assertRedirect(); + + expect($product->fresh()->is_active)->toBeFalse(); + }); + + test('guest cannot toggle product status', function () { + $product = createProductWithVariants(); + + $this->patch(route('admin.master.products.toggle_status', $product), [ + 'is_active' => false, + ])->assertRedirect(route('login')); + }); + + test('user without toggle permission is forbidden', function () { + $user = createProductUserWithPermission(PermissionEnum::PRODUCTS_VIEW); + + $product = createProductWithVariants(); + + $this->actingAs($user) + ->patch(route('admin.master.products.toggle_status', $product), [ + 'is_active' => false, + ]) + ->assertForbidden(); + }); + + test('is_active field is required', function () { + $user = createProductUserWithPermission(PermissionEnum::PRODUCTS_VIEW, PermissionEnum::PRODUCTS_TOGGLE_STATUS); + + $product = createProductWithVariants(); + + $this->actingAs($user) + ->patch(route('admin.master.products.toggle_status', $product), [ + 'is_active' => null, + ]) + ->assertSessionHasErrors('is_active'); + }); +}); + +// ─── Destroy ────────────────────────────────────────────── + +describe('Product Destroy', function () { + test('authenticated user with permission can delete a product', function () { + $user = createProductUserWithPermission(PermissionEnum::PRODUCTS_VIEW, PermissionEnum::PRODUCTS_DELETE); + + $product = createProductWithVariants(); + + $this->actingAs($user) + ->delete(route('admin.master.products.destroy', $product)) + ->assertRedirect(route('admin.master.products.index')); + + $this->assertSoftDeleted('products', ['id' => $product->id]); + }); + + test('guest cannot delete a product', function () { + $product = createProductWithVariants(); + + $this->delete(route('admin.master.products.destroy', $product)) + ->assertRedirect(route('login')); + + $this->assertNotSoftDeleted('products', ['id' => $product->id]); + }); + + test('user without delete permission is forbidden', function () { + $user = createProductUserWithPermission(PermissionEnum::PRODUCTS_VIEW); + + $product = createProductWithVariants(); + + $this->actingAs($user) + ->delete(route('admin.master.products.destroy', $product)) + ->assertForbidden(); + + $this->assertNotSoftDeleted('products', ['id' => $product->id]); + }); + + test('deleting product also soft deletes variants', function () { + $user = createProductUserWithPermission(PermissionEnum::PRODUCTS_VIEW, PermissionEnum::PRODUCTS_DELETE); + + $product = createProductWithVariants(); + $variantIds = $product->variants->pluck('id')->toArray(); + + $this->actingAs($user) + ->delete(route('admin.master.products.destroy', $product)); + + $this->assertSoftDeleted('products', ['id' => $product->id]); + + foreach ($variantIds as $variantId) { + $this->assertSoftDeleted('product_variants', ['id' => $variantId]); + } + }); + + test('deleting product detaches categories', function () { + $user = createProductUserWithPermission(PermissionEnum::PRODUCTS_VIEW, PermissionEnum::PRODUCTS_DELETE); + + $product = createProductWithVariants(); + + $this->actingAs($user) + ->delete(route('admin.master.products.destroy', $product)); + + $this->assertDatabaseMissing('product_categories', ['product_id' => $product->id]); + }); +}); + +// ─── Product Model ──────────────────────────────────────── + +describe('Product Model', function () { + test('product uses soft deletes', function () { + $product = Product::factory()->create(); + + $product->delete(); + + expect($product->trashed())->toBeTrue(); + }); + + test('product can have categories', function () { + $product = Product::factory()->create(); + $category = Category::factory()->create(); + $product->categories()->attach($category->id); + + expect($product->fresh()->categories)->toHaveCount(1); + }); + + test('product can have variants', function () { + $product = Product::factory()->create(); + ProductVariant::factory()->count(3)->create(['product_id' => $product->id]); + + expect($product->fresh()->variants)->toHaveCount(3); + }); + + test('product has is_active cast to boolean', function () { + $product = Product::factory()->create(['is_active' => true]); + + expect($product->is_active)->toBeTrue(); + + $product->update(['is_active' => false]); + + expect($product->fresh()->is_active)->toBeFalse(); + }); +});