create();
return array_merge([
'name' => 'Produk Test',
'description' => 'Deskripsi produk',
'status' => 'active',
'category_ids' => [$category->id],
'use_same_price' => false,
'shared_prices' => [],
'variants' => [
[
'name' => 'Varian Default',
'stock' => 100,
'reject_stock' => 10,
'retail_stock' => 20,
'photo_key' => 'product-variant/test-photo.jpg',
'prices' => [
['type' => 'distributor', 'price' => 10000],
['type' => 'agent', 'price' => 11000],
['type' => 'sub_agent', 'price' => 12000],
['type' => 'wholesale', 'price' => 13000],
['type' => 'retail', 'price' => 15000],
['type' => 'tiktok', 'price' => 16000],
['type' => 'shopee', 'price' => 16500],
['type' => 'capital', 'price' => 8000],
['type' => 'reject', 'price' => 5000],
],
],
],
], $overrides);
}
function makeValidSharedPricePayload(array $overrides = []): array
{
$category = Category::factory()->create();
return array_merge([
'name' => 'Produk Shared Price',
'description' => null,
'status' => 'active',
'category_ids' => [$category->id],
'use_same_price' => true,
'shared_prices' => [
['type' => 'distributor', 'price' => 10000],
['type' => 'agent', 'price' => 11000],
['type' => 'sub_agent', 'price' => 12000],
['type' => 'wholesale', 'price' => 13000],
['type' => 'retail', 'price' => 15000],
['type' => 'tiktok', 'price' => 16000],
['type' => 'shopee', 'price' => 16500],
['type' => 'capital', 'price' => 8000],
['type' => 'reject', 'price' => 5000],
],
'variants' => [
[
'name' => 'Varian Shared',
'stock' => 50,
'reject_stock' => 5,
'retail_stock' => 10,
'photo_key' => 'product-variant/shared-photo.jpg',
'prices' => [],
],
],
], $overrides);
}
function allPriceTypes(): array
{
return [
['type' => 'distributor', 'price' => 10000],
['type' => 'agent', 'price' => 11000],
['type' => 'sub_agent', 'price' => 12000],
['type' => 'wholesale', 'price' => 13000],
['type' => 'retail', 'price' => 15000],
['type' => 'tiktok', 'price' => 16000],
['type' => 'shopee', 'price' => 16500],
['type' => 'capital', 'price' => 8000],
['type' => 'reject', 'price' => 5000],
];
}
/*
|--------------------------------------------------------------------------
| AUTHENTICATION
|--------------------------------------------------------------------------
*/
test('guests are redirected to the login page', function () {
$response = $this->get(route('admin.master.products.index'));
$response->assertRedirect(route('login'));
});
test('guests are redirected when visiting create page', function () {
$response = $this->get(route('admin.master.products.create'));
$response->assertRedirect(route('login'));
});
test('guests are redirected when visiting edit page', function () {
$product = Product::factory()->create();
$response = $this->get(route('admin.master.products.edit', $product));
$response->assertRedirect(route('login'));
});
test('guest cannot create product', function () {
$response = $this->post(route('admin.master.products.store'), makeValidProductPayload());
$response->assertRedirect(route('login'));
$this->assertDatabaseCount('products', 0);
});
test('guest cannot update product', function () {
$product = Product::factory()->create();
$response = $this->put(route('admin.master.products.update', $product), makeValidProductPayload());
$response->assertRedirect(route('login'));
});
test('guest cannot delete product', function () {
$product = Product::factory()->create();
$response = $this->delete(route('admin.master.products.destroy', $product));
$response->assertRedirect(route('login'));
$this->assertDatabaseHas('products', ['id' => $product->id, 'deleted_at' => null]);
});
test('guest cannot toggle product status', function () {
$product = Product::factory()->create();
$response = $this->post(route('admin.master.products.toggle-status', $product));
$response->assertRedirect(route('login'));
});
test('authenticated users can visit the product index page', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->get(route('admin.master.products.index'));
$response->assertOk();
});
test('authenticated users can visit the product create page', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->get(route('admin.master.products.create'));
$response->assertOk();
});
test('authenticated users can visit the product edit page', function () {
$user = User::factory()->create();
$this->actingAs($user);
$product = Product::factory()->create();
$response = $this->get(route('admin.master.products.edit', $product));
$response->assertOk();
});
/*
|--------------------------------------------------------------------------
| INDEX PAGE
|--------------------------------------------------------------------------
*/
test('product index page displays products', function () {
$user = User::factory()->create();
$this->actingAs($user);
Product::factory()->count(3)->create();
$response = $this->get(route('admin.master.products.index'));
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->component('admin/master/product/index')
->has('products', 3)
);
});
test('index page works with zero products', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->get(route('admin.master.products.index'));
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->component('admin/master/product/index')
->has('products', 0)
);
});
test('index page does not display soft-deleted products', function () {
$user = User::factory()->create();
$this->actingAs($user);
Product::factory()->create(['name' => 'Active Product']);
Product::factory()->create(['name' => 'Deleted Product'])->delete();
$response = $this->get(route('admin.master.products.index'));
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->component('admin/master/product/index')
->has('products', 1)
->where('products.0.name', 'Active Product')
);
});
test('index page displays correct count after delete', function () {
$user = User::factory()->create();
$this->actingAs($user);
Product::factory()->count(5)->create();
$product = Product::first();
$this->delete(route('admin.master.products.destroy', $product));
$response = $this->get(route('admin.master.products.index'));
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->component('admin/master/product/index')
->has('products', 4)
);
});
test('index page includes product categories', function () {
$user = User::factory()->create();
$this->actingAs($user);
$product = Product::factory()->create();
$category = Category::factory()->create();
DB::table('product_categories')->insert([
'product_id' => $product->id,
'category_id' => $category->id,
]);
$response = $this->get(route('admin.master.products.index'));
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->component('admin/master/product/index')
->has('products.0.categories', 1)
);
});
test('index page includes product variants', function () {
$user = User::factory()->create();
$this->actingAs($user);
$product = Product::factory()->create();
ProductVariant::factory()->create(['product_id' => $product->id]);
$response = $this->get(route('admin.master.products.index'));
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->component('admin/master/product/index')
->has('products.0.product_variants', 1)
);
});
test('index page includes product variant prices', function () {
$user = User::factory()->create();
$this->actingAs($user);
$product = Product::factory()->create();
$variant = ProductVariant::factory()->create(['product_id' => $product->id]);
ProductPrice::factory()->create(['variant_id' => $variant->id, 'type' => 'retail', 'price' => 15000]);
$response = $this->get(route('admin.master.products.index'));
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->component('admin/master/product/index')
->has('products.0.product_variants.0.product_prices', 1)
);
});
/*
|--------------------------------------------------------------------------
| CREATE / STORE - BASIC
|--------------------------------------------------------------------------
*/
test('product can be created with per-variant prices', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->post(route('admin.master.products.store'), makeValidProductPayload());
$response
->assertSessionHasNoErrors()
->assertRedirect(route('admin.master.products.index'));
$this->assertDatabaseHas('products', [
'name' => 'Produk Test',
'status' => 'active',
]);
$this->assertDatabaseCount('product_variants', 1);
$this->assertDatabaseCount('product_prices', 9);
});
test('product can be created with shared prices', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->post(route('admin.master.products.store'), makeValidSharedPricePayload());
$response
->assertSessionHasNoErrors()
->assertRedirect(route('admin.master.products.index'));
$this->assertDatabaseHas('products', ['name' => 'Produk Shared Price']);
$this->assertDatabaseCount('product_variants', 1);
$this->assertDatabaseCount('product_prices', 9);
});
test('product slug is automatically generated from name', function () {
$user = User::factory()->create();
$this->actingAs($user);
$this->post(route('admin.master.products.store'), makeValidProductPayload(['name' => 'Produk Otomatis']));
$this->assertDatabaseHas('products', [
'name' => 'Produk Otomatis',
'slug' => 'produk-otomatis',
]);
});
test('product can be created with multiple categories', function () {
$user = User::factory()->create();
$this->actingAs($user);
$categories = Category::factory()->count(3)->create();
$response = $this->post(route('admin.master.products.store'), makeValidProductPayload([
'category_ids' => $categories->pluck('id')->toArray(),
]));
$response->assertSessionHasNoErrors();
$product = Product::where('name', 'Produk Test')->first();
$this->assertCount(3, $product->categories);
});
test('product can be created with multiple variants', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->post(route('admin.master.products.store'), makeValidProductPayload([
'variants' => [
[
'name' => 'Varian 1',
'stock' => 10,
'reject_stock' => 1,
'retail_stock' => 2,
'photo_key' => 'product-variant/v1.jpg',
'prices' => allPriceTypes(),
],
[
'name' => 'Varian 2',
'stock' => 20,
'reject_stock' => 2,
'retail_stock' => 4,
'photo_key' => 'product-variant/v2.jpg',
'prices' => array_map(fn($p) => ['type' => $p['type'], 'price' => $p['price'] * 2], allPriceTypes()),
],
],
]));
$response->assertSessionHasNoErrors();
$this->assertDatabaseCount('product_variants', 2);
$this->assertDatabaseCount('product_prices', 18);
});
test('product can be created with zero price', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->post(route('admin.master.products.store'), makeValidProductPayload([
'variants' => [[
'name' => 'Varian Gratis',
'stock' => 10,
'reject_stock' => 0,
'retail_stock' => 0,
'photo_key' => 'product-variant/free.jpg',
'prices' => array_map(fn($p) => ['type' => $p['type'], 'price' => 0], allPriceTypes()),
]],
]));
$response->assertSessionHasNoErrors();
$variant = ProductVariant::where('name', 'Varian Gratis')->first();
$prices = ProductPrice::where('variant_id', $variant->id)->get();
expect($prices->pluck('price')->toArray())->toEqual([0, 0, 0, 0, 0, 0, 0, 0, 0]);
});
test('product can be created with very large price', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->post(route('admin.master.products.store'), makeValidProductPayload([
'variants' => [[
'name' => 'Varian Mahal',
'stock' => 1,
'reject_stock' => 0,
'retail_stock' => 0,
'photo_key' => 'product-variant/expensive.jpg',
'prices' => array_map(fn($p) => ['type' => $p['type'], 'price' => 999999999], allPriceTypes()),
]],
]));
$response->assertSessionHasNoErrors();
});
test('product can be created with zero stock', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->post(route('admin.master.products.store'), makeValidProductPayload([
'variants' => [[
'name' => 'Varian Kosong',
'stock' => 0,
'reject_stock' => 0,
'retail_stock' => 0,
'photo_key' => 'product-variant/empty.jpg',
'prices' => allPriceTypes(),
]],
]));
$response->assertSessionHasNoErrors();
});
test('product can be created with default status when not provided', function () {
$user = User::factory()->create();
$this->actingAs($user);
$payload = makeValidProductPayload();
unset($payload['status']);
$response = $this->post(route('admin.master.products.store'), $payload);
$response->assertSessionHasNoErrors();
$product = Product::where('name', 'Produk Test')->first();
expect($product->status->value)->toBe('active');
});
test('product can be created as draft', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->post(route('admin.master.products.store'), makeValidProductPayload(['status' => 'draft']));
$response->assertSessionHasNoErrors();
$product = Product::where('name', 'Produk Test')->first();
expect($product->status->value)->toBe('draft');
});
test('product can be created as inactive', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->post(route('admin.master.products.store'), makeValidProductPayload(['status' => 'inactive']));
$response->assertSessionHasNoErrors();
$product = Product::where('name', 'Produk Test')->first();
expect($product->status->value)->toBe('inactive');
});
test('product can be created without description', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->post(route('admin.master.products.store'), makeValidProductPayload(['description' => null]));
$response->assertSessionHasNoErrors();
$product = Product::where('name', 'Produk Test')->first();
expect($product->description)->toBeNull();
});
test('store flashes success toast via Inertia', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->post(route('admin.master.products.store'), makeValidProductPayload());
$response->assertRedirect();
});
test('store creates new product in database', function () {
$user = User::factory()->create();
$this->actingAs($user);
$this->post(route('admin.master.products.store'), makeValidProductPayload());
$this->assertDatabaseCount('products', 1);
$this->assertDatabaseHas('products', [
'name' => 'Produk Test',
'slug' => 'produk-test',
]);
});
test('multiple products can be created sequentially', function () {
$user = User::factory()->create();
$this->actingAs($user);
$this->post(route('admin.master.products.store'), makeValidProductPayload(['name' => 'Produk 1']));
$this->post(route('admin.master.products.store'), makeValidProductPayload(['name' => 'Produk 2']));
$this->post(route('admin.master.products.store'), makeValidProductPayload(['name' => 'Produk 3']));
$this->assertDatabaseCount('products', 3);
});
/*
|--------------------------------------------------------------------------
| STORE VALIDATION - REQUIRED FIELDS
|--------------------------------------------------------------------------
*/
test('product name is required', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->post(route('admin.master.products.store'), makeValidProductPayload(['name' => '']));
$response->assertSessionHasErrors('name');
});
test('product name must be string', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->post(route('admin.master.products.store'), makeValidProductPayload(['name' => 12345]));
$response->assertSessionHasErrors('name');
});
test('product name must not exceed 200 characters', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->post(route('admin.master.products.store'), makeValidProductPayload(['name' => str_repeat('a', 201)]));
$response->assertSessionHasErrors('name');
});
test('product name exactly 200 characters passes validation', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->post(route('admin.master.products.store'), makeValidProductPayload(['name' => str_repeat('a', 200)]));
$response->assertSessionHasNoErrors();
});
test('product name exactly 1 character passes validation', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->post(route('admin.master.products.store'), makeValidProductPayload(['name' => 'A']));
$response->assertSessionHasNoErrors();
});
test('product name with only whitespace is rejected', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->post(route('admin.master.products.store'), makeValidProductPayload(['name' => ' ']));
$response->assertSessionHasErrors('name');
});
test('product category_ids is required', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->post(route('admin.master.products.store'), makeValidProductPayload(['category_ids' => []]));
$response->assertSessionHasErrors('category_ids');
});
test('product category_ids must be array', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->post(route('admin.master.products.store'), makeValidProductPayload(['category_ids' => 'not-an-array']));
$response->assertSessionHasErrors('category_ids');
});
test('product category_ids.* must exist in categories table', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->post(route('admin.master.products.store'), makeValidProductPayload(['category_ids' => [999999]]));
$response->assertSessionHasErrors('category_ids.0');
});
test('product variants is required', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->post(route('admin.master.products.store'), makeValidProductPayload(['variants' => []]));
$response->assertSessionHasErrors('variants');
});
test('product variants must be array', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->post(route('admin.master.products.store'), makeValidProductPayload(['variants' => 'not-an-array']));
$response->assertSessionHasErrors('variants');
});
test('product variant name is required', function () {
$user = User::factory()->create();
$this->actingAs($user);
$payload = makeValidProductPayload();
$payload['variants'][0]['name'] = '';
$response = $this->post(route('admin.master.products.store'), $payload);
$response->assertSessionHasErrors('variants.0.name');
});
test('product variant stock is required', function () {
$user = User::factory()->create();
$this->actingAs($user);
$payload = makeValidProductPayload();
unset($payload['variants'][0]['stock']);
$response = $this->post(route('admin.master.products.store'), $payload);
$response->assertSessionHasErrors('variants.0.stock');
});
test('product variant stock must be integer', function () {
$user = User::factory()->create();
$this->actingAs($user);
$payload = makeValidProductPayload();
$payload['variants'][0]['stock'] = 'abc';
$response = $this->post(route('admin.master.products.store'), $payload);
$response->assertSessionHasErrors('variants.0.stock');
});
test('product variant stock must be >= 0', function () {
$user = User::factory()->create();
$this->actingAs($user);
$payload = makeValidProductPayload();
$payload['variants'][0]['stock'] = -1;
$response = $this->post(route('admin.master.products.store'), $payload);
$response->assertSessionHasErrors('variants.0.stock');
});
test('product variant reject_stock must be >= 0', function () {
$user = User::factory()->create();
$this->actingAs($user);
$payload = makeValidProductPayload();
$payload['variants'][0]['reject_stock'] = -5;
$response = $this->post(route('admin.master.products.store'), $payload);
$response->assertSessionHasErrors('variants.0.reject_stock');
});
test('product variant retail_stock must be >= 0', function () {
$user = User::factory()->create();
$this->actingAs($user);
$payload = makeValidProductPayload();
$payload['variants'][0]['retail_stock'] = -3;
$response = $this->post(route('admin.master.products.store'), $payload);
$response->assertSessionHasErrors('variants.0.retail_stock');
});
test('product variant photo_key is required', function () {
$user = User::factory()->create();
$this->actingAs($user);
$payload = makeValidProductPayload();
unset($payload['variants'][0]['photo_key']);
$response = $this->post(route('admin.master.products.store'), $payload);
$response->assertSessionHasErrors('variants.0.photo_key');
});
/*
|--------------------------------------------------------------------------
| STORE VALIDATION - STATUS
|--------------------------------------------------------------------------
*/
test('product status must be valid enum value', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->post(route('admin.master.products.store'), makeValidProductPayload(['status' => 'invalid_status']));
$response->assertSessionHasErrors('status');
});
test('product status accepts active', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->post(route('admin.master.products.store'), makeValidProductPayload(['status' => 'active']));
$response->assertSessionHasNoErrors();
});
test('product status accepts inactive', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->post(route('admin.master.products.store'), makeValidProductPayload(['status' => 'inactive']));
$response->assertSessionHasNoErrors();
});
test('product status accepts draft', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->post(route('admin.master.products.store'), makeValidProductPayload(['status' => 'draft']));
$response->assertSessionHasNoErrors();
});
/*
|--------------------------------------------------------------------------
| STORE VALIDATION - PRICES
|--------------------------------------------------------------------------
*/
test('product prices are required when use_same_price is false', function () {
$user = User::factory()->create();
$this->actingAs($user);
$payload = makeValidProductPayload();
$payload['variants'][0]['prices'] = [];
$response = $this->post(route('admin.master.products.store'), $payload);
$response->assertSessionHasErrors('variants.0.prices');
});
test('product prices must have exactly 9 items when use_same_price is false', function () {
$user = User::factory()->create();
$this->actingAs($user);
$payload = makeValidProductPayload();
$payload['variants'][0]['prices'] = [
['type' => 'retail', 'price' => 10000],
['type' => 'wholesale', 'price' => 12000],
];
$response = $this->post(route('admin.master.products.store'), $payload);
$response->assertSessionHasErrors('variants.0.prices');
});
test('product price type must be valid PriceType enum', function () {
$user = User::factory()->create();
$this->actingAs($user);
$payload = makeValidProductPayload();
$payload['variants'][0]['prices'][0]['type'] = 'invalid_type';
$response = $this->post(route('admin.master.products.store'), $payload);
$response->assertSessionHasErrors('variants.0.prices.0.type');
});
test('product price must be integer', function () {
$user = User::factory()->create();
$this->actingAs($user);
$payload = makeValidProductPayload();
$payload['variants'][0]['prices'][0]['price'] = 'not-a-number';
$response = $this->post(route('admin.master.products.store'), $payload);
$response->assertSessionHasErrors('variants.0.prices.0.price');
});
test('product price must be >= 0', function () {
$user = User::factory()->create();
$this->actingAs($user);
$payload = makeValidProductPayload();
$payload['variants'][0]['prices'][0]['price'] = -100;
$response = $this->post(route('admin.master.products.store'), $payload);
$response->assertSessionHasErrors('variants.0.prices.0.price');
});
test('shared_prices are required when use_same_price is true', function () {
$user = User::factory()->create();
$this->actingAs($user);
$payload = makeValidSharedPricePayload();
$payload['shared_prices'] = [];
$response = $this->post(route('admin.master.products.store'), $payload);
$response->assertSessionHasErrors('shared_prices');
});
test('shared_prices must have exactly 9 items when use_same_price is true', function () {
$user = User::factory()->create();
$this->actingAs($user);
$payload = makeValidSharedPricePayload();
$payload['shared_prices'] = [
['type' => 'retail', 'price' => 10000],
];
$response = $this->post(route('admin.master.products.store'), $payload);
$response->assertSessionHasErrors('shared_prices');
});
test('shared_price type must be valid PriceType enum', function () {
$user = User::factory()->create();
$this->actingAs($user);
$payload = makeValidSharedPricePayload();
$payload['shared_prices'][0]['type'] = 'invalid_type';
$response = $this->post(route('admin.master.products.store'), $payload);
$response->assertSessionHasErrors('shared_prices.0.type');
});
test('shared_price must be integer >= 0', function () {
$user = User::factory()->create();
$this->actingAs($user);
$payload = makeValidSharedPricePayload();
$payload['shared_prices'][0]['price'] = -500;
$response = $this->post(route('admin.master.products.store'), $payload);
$response->assertSessionHasErrors('shared_prices.0.price');
});
/*
|--------------------------------------------------------------------------
| EDIT PAGE
|--------------------------------------------------------------------------
*/
test('edit page shows product data', function () {
$user = User::factory()->create();
$this->actingAs($user);
$product = Product::factory()->create(['name' => 'Produk Edit']);
$category = Category::factory()->create();
DB::table('product_categories')->insert([
'product_id' => $product->id,
'category_id' => $category->id,
]);
$variant = ProductVariant::factory()->create(['product_id' => $product->id]);
ProductPrice::factory()->create(['variant_id' => $variant->id, 'type' => 'retail', 'price' => 15000]);
$response = $this->get(route('admin.master.products.edit', $product));
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->component('admin/master/product/edit')
->where('product.name', 'Produk Edit')
->has('product.product_variants', 1)
);
});
/*
|--------------------------------------------------------------------------
| UPDATE / PUT
|--------------------------------------------------------------------------
*/
test('product can be updated', function () {
$user = User::factory()->create();
$this->actingAs($user);
$product = Product::factory()->create();
$category = Category::factory()->create();
DB::table('product_categories')->insert([
'product_id' => $product->id,
'category_id' => $category->id,
]);
$variant = ProductVariant::factory()->create(['product_id' => $product->id, 'name' => 'Existing']);
foreach (allPriceTypes() as $price) {
ProductPrice::factory()->create(['variant_id' => $variant->id, 'type' => $price['type'], 'price' => $price['price']]);
}
$response = $this->put(route('admin.master.products.update', $product), makeValidProductPayload([
'name' => 'Produk Updated',
'category_ids' => [$category->id],
'variants' => [[
'id' => $variant->id,
'name' => 'Existing Updated',
'stock' => 100,
'reject_stock' => 10,
'retail_stock' => 20,
'photo_key' => 'product-variant/existing.jpg',
'prices' => allPriceTypes(),
]],
]));
$response
->assertSessionHasNoErrors()
->assertRedirect();
$product->refresh();
expect($product->name)->toBe('Produk Updated');
});
test('product update name is required', function () {
$user = User::factory()->create();
$this->actingAs($user);
$product = Product::factory()->create();
$category = Category::factory()->create();
DB::table('product_categories')->insert([
'product_id' => $product->id,
'category_id' => $category->id,
]);
$variant = ProductVariant::factory()->create(['product_id' => $product->id]);
foreach (allPriceTypes() as $price) {
ProductPrice::factory()->create(['variant_id' => $variant->id, 'type' => $price['type'], 'price' => $price['price']]);
}
$response = $this->put(route('admin.master.products.update', $product), makeValidProductPayload(['name' => '']));
$response->assertSessionHasErrors('name');
});
test('product update name must not exceed 200 characters', function () {
$user = User::factory()->create();
$this->actingAs($user);
$product = Product::factory()->create();
$category = Category::factory()->create();
DB::table('product_categories')->insert([
'product_id' => $product->id,
'category_id' => $category->id,
]);
$variant = ProductVariant::factory()->create(['product_id' => $product->id]);
foreach (allPriceTypes() as $price) {
ProductPrice::factory()->create(['variant_id' => $variant->id, 'type' => $price['type'], 'price' => $price['price']]);
}
$response = $this->put(route('admin.master.products.update', $product), makeValidProductPayload(['name' => str_repeat('a', 201)]));
$response->assertSessionHasErrors('name');
});
test('updating non-existent product returns 404', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->put(route('admin.master.products.update', 999999), makeValidProductPayload(['name' => 'Ghost']));
$response->assertStatus(404);
});
test('update can change product status', function () {
$user = User::factory()->create();
$this->actingAs($user);
$product = Product::factory()->create(['status' => 'active']);
$category = Category::factory()->create();
DB::table('product_categories')->insert([
'product_id' => $product->id,
'category_id' => $category->id,
]);
$variant = ProductVariant::factory()->create(['product_id' => $product->id, 'name' => 'V1']);
foreach (allPriceTypes() as $price) {
ProductPrice::factory()->create(['variant_id' => $variant->id, 'type' => $price['type'], 'price' => $price['price']]);
}
$this->put(route('admin.master.products.update', $product), makeValidProductPayload([
'status' => 'draft',
'category_ids' => [$category->id],
'variants' => [[
'id' => $variant->id,
'name' => 'V1',
'stock' => 100,
'reject_stock' => 10,
'retail_stock' => 20,
'photo_key' => 'product-variant/v1.jpg',
'prices' => allPriceTypes(),
]],
]));
$product->refresh();
expect($product->status->value)->toBe('draft');
});
test('update can change categories', function () {
$user = User::factory()->create();
$this->actingAs($user);
$product = Product::factory()->create();
$oldCategory = Category::factory()->create();
$newCategory = Category::factory()->create();
DB::table('product_categories')->insert([
'product_id' => $product->id,
'category_id' => $oldCategory->id,
]);
$variant = ProductVariant::factory()->create(['product_id' => $product->id, 'name' => 'V1']);
foreach (allPriceTypes() as $price) {
ProductPrice::factory()->create(['variant_id' => $variant->id, 'type' => $price['type'], 'price' => $price['price']]);
}
$this->put(route('admin.master.products.update', $product), makeValidProductPayload([
'category_ids' => [$newCategory->id],
'variants' => [[
'id' => $variant->id,
'name' => 'V1',
'stock' => 100,
'reject_stock' => 10,
'retail_stock' => 20,
'photo_key' => 'product-variant/v1.jpg',
'prices' => allPriceTypes(),
]],
]));
$product->refresh();
$this->assertCount(1, $product->categories);
expect($product->categories->first()->id)->toBe($newCategory->id);
});
test('update replaces old variants with new ones', function () {
$user = User::factory()->create();
$this->actingAs($user);
$this->post(route('admin.master.products.store'), makeValidProductPayload(['name' => 'To Replace']));
$product = Product::where('name', 'To Replace')->first();
$category = $product->categories->first();
$oldVariant = $product->productVariants->first();
$oldVariantId = $oldVariant->id;
$this->put(route('admin.master.products.update', $product), makeValidProductPayload([
'name' => 'Replaced',
'category_ids' => [$category->id],
'variants' => [[
'id' => $oldVariantId,
'name' => 'New Variant',
'stock' => 42,
'reject_stock' => 3,
'retail_stock' => 7,
'photo_key' => 'product-variant/new.jpg',
'prices' => allPriceTypes(),
]],
]));
$product->refresh();
$variant = ProductVariant::where('id', $oldVariantId)->first();
expect($variant->name)->toBe('New Variant');
expect($variant->stock)->toBe(42);
});
test('update can modify existing variant data', function () {
$user = User::factory()->create();
$this->actingAs($user);
$this->post(route('admin.master.products.store'), makeValidProductPayload(['name' => 'Modify Variant']));
$product = Product::where('name', 'Modify Variant')->first();
$category = $product->categories->first();
$existingVariant = $product->productVariants->first();
$this->put(route('admin.master.products.update', $product), makeValidProductPayload([
'name' => 'Modify Variant',
'category_ids' => [$category->id],
'variants' => [
[
'id' => $existingVariant->id,
'name' => 'Updated Variant Name',
'stock' => 999,
'reject_stock' => 50,
'retail_stock' => 75,
'photo_key' => 'product-variant/updated.jpg',
'prices' => allPriceTypes(),
],
],
]));
$product->refresh();
$variant = $product->productVariants->first();
expect($variant->name)->toBe('Updated Variant Name');
expect($variant->stock)->toBe(999);
expect($variant->reject_stock)->toBe(50);
expect($variant->retail_stock)->toBe(75);
});
/*
|--------------------------------------------------------------------------
| DELETE / DESTROY
|--------------------------------------------------------------------------
*/
test('product can be deleted', function () {
$user = User::factory()->create();
$this->actingAs($user);
$product = Product::factory()->create();
$response = $this->delete(route('admin.master.products.destroy', $product));
$response
->assertSessionHasNoErrors()
->assertRedirect(route('admin.master.products.index'));
$this->assertSoftDeleted('products', ['id' => $product->id]);
});
test('product is soft-deleted not permanently removed', function () {
$user = User::factory()->create();
$this->actingAs($user);
$product = Product::factory()->create();
$this->delete(route('admin.master.products.destroy', $product));
$this->assertDatabaseHas('products', ['id' => $product->id]);
$this->assertSoftDeleted('products', ['id' => $product->id]);
});
test('soft-deleted product has deleted_at timestamp', function () {
$user = User::factory()->create();
$this->actingAs($user);
$product = Product::factory()->create();
$this->delete(route('admin.master.products.destroy', $product));
$product->refresh();
expect($product->deleted_at)->not->toBeNull();
});
test('delete cascades to product variants', function () {
$user = User::factory()->create();
$this->actingAs($user);
$product = Product::factory()->create();
$variant = ProductVariant::factory()->create(['product_id' => $product->id]);
$this->delete(route('admin.master.products.destroy', $product));
$this->assertSoftDeleted('product_variants', ['id' => $variant->id]);
});
test('delete cascades to product prices', function () {
$user = User::factory()->create();
$this->actingAs($user);
$product = Product::factory()->create();
$variant = ProductVariant::factory()->create(['product_id' => $product->id]);
ProductPrice::factory()->create(['variant_id' => $variant->id, 'type' => 'retail', 'price' => 15000]);
$this->delete(route('admin.master.products.destroy', $product));
$this->assertDatabaseCount('product_prices', 0);
});
test('delete detaches product categories', function () {
$user = User::factory()->create();
$this->actingAs($user);
$product = Product::factory()->create();
$category = Category::factory()->create();
DB::table('product_categories')->insert([
'product_id' => $product->id,
'category_id' => $category->id,
]);
$this->delete(route('admin.master.products.destroy', $product));
$this->assertDatabaseCount('product_categories', 0);
});
test('deleting non-existent product returns 404', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->delete(route('admin.master.products.destroy', 999999));
$response->assertStatus(404);
});
test('updating soft-deleted product via route returns 404', function () {
$user = User::factory()->create();
$this->actingAs($user);
$product = Product::factory()->create();
$product->delete();
$response = $this->put(route('admin.master.products.update', $product), makeValidProductPayload(['name' => 'Nope']));
$response->assertStatus(404);
});
test('deleting soft-deleted product via route returns 404', function () {
$user = User::factory()->create();
$this->actingAs($user);
$product = Product::factory()->create();
$product->delete();
$response = $this->delete(route('admin.master.products.destroy', $product));
$response->assertStatus(404);
});
/*
|--------------------------------------------------------------------------
| TOGGLE STATUS
|--------------------------------------------------------------------------
*/
test('toggle status changes active to inactive', function () {
$user = User::factory()->create();
$this->actingAs($user);
$product = Product::factory()->create(['status' => 'active']);
$this->post(route('admin.master.products.toggle-status', $product));
$product->refresh();
expect($product->status->value)->toBe('inactive');
});
test('toggle status changes inactive to active', function () {
$user = User::factory()->create();
$this->actingAs($user);
$product = Product::factory()->create(['status' => 'inactive']);
$this->post(route('admin.master.products.toggle-status', $product));
$product->refresh();
expect($product->status->value)->toBe('active');
});
test('toggle status changes draft to active', function () {
$user = User::factory()->create();
$this->actingAs($user);
$product = Product::factory()->create(['status' => 'draft']);
$this->post(route('admin.master.products.toggle-status', $product));
$product->refresh();
expect($product->status->value)->toBe('active');
});
test('toggle status changes active to inactive then back to active', function () {
$user = User::factory()->create();
$this->actingAs($user);
$product = Product::factory()->create(['status' => 'active']);
$this->post(route('admin.master.products.toggle-status', $product));
$product->refresh();
expect($product->status->value)->toBe('inactive');
$this->post(route('admin.master.products.toggle-status', $product));
$product->refresh();
expect($product->status->value)->toBe('active');
});
test('toggle status redirects to index', function () {
$user = User::factory()->create();
$this->actingAs($user);
$product = Product::factory()->create(['status' => 'active']);
$response = $this->post(route('admin.master.products.toggle-status', $product));
$response->assertRedirect(route('admin.master.products.index'));
});
test('toggle status on non-existent product returns 404', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->post(route('admin.master.products.toggle-status', 999999));
$response->assertStatus(404);
});
/*
|--------------------------------------------------------------------------
| WEIRD / EDGE CASE USER INPUTS
|--------------------------------------------------------------------------
*/
test('product name with special characters is accepted', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->post(route('admin.master.products.store'), makeValidProductPayload([
'name' => 'Produk & Co. (PT) - Best!',
]));
$response->assertSessionHasNoErrors();
$this->assertDatabaseHas('products', ['name' => 'Produk & Co. (PT) - Best!']);
});
test('product name with HTML tags is accepted as plain text', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->post(route('admin.master.products.store'), makeValidProductPayload([
'name' => '',
]));
$response->assertSessionHasNoErrors();
$this->assertDatabaseHas('products', ['name' => '']);
});
test('product name with emoji is accepted', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->post(route('admin.master.products.store'), makeValidProductPayload([
'name' => '🔥 Hot Product 🏷️',
]));
$response->assertSessionHasNoErrors();
});
test('product name with unicode characters is accepted', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->post(route('admin.master.products.store'), makeValidProductPayload([
'name' => ' Produk Žöžička ',
]));
$response->assertSessionHasNoErrors();
});
test('product name with chinese characters is accepted', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->post(route('admin.master.products.store'), makeValidProductPayload([
'name' => '电子产品',
]));
$response->assertSessionHasNoErrors();
});
test('product name with japanese characters is accepted', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->post(route('admin.master.products.store'), makeValidProductPayload([
'name' => '製品テスト',
]));
$response->assertSessionHasNoErrors();
});
test('product name with arabic characters is accepted', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->post(route('admin.master.products.store'), makeValidProductPayload([
'name' => 'المنتج',
]));
$response->assertSessionHasNoErrors();
});
test('product name with only numbers is accepted', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->post(route('admin.master.products.store'), makeValidProductPayload([
'name' => '12345',
]));
$response->assertSessionHasNoErrors();
$this->assertDatabaseHas('products', ['name' => '12345', 'slug' => '12345']);
});
test('product name with email-like pattern is accepted', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->post(route('admin.master.products.store'), makeValidProductPayload([
'name' => 'product@example.com',
]));
$response->assertSessionHasNoErrors();
});
test('product name with URL-like pattern is accepted', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->post(route('admin.master.products.store'), makeValidProductPayload([
'name' => 'https://example.com/product',
]));
$response->assertSessionHasNoErrors();
});
test('product name with SQL injection attempt is accepted', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->post(route('admin.master.products.store'), makeValidProductPayload([
'name' => "'; DROP TABLE products; --",
]));
$response->assertSessionHasNoErrors();
$this->assertDatabaseHas('products', ['name' => "'; DROP TABLE products; --"]);
});
test('product variant name with weird characters is accepted', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->post(route('admin.master.products.store'), makeValidProductPayload([
'variants' => [[
'name' => 'Size XL (🔥)',
'stock' => 10,
'reject_stock' => 0,
'retail_stock' => 0,
'photo_key' => 'product-variant/weird.jpg',
'prices' => allPriceTypes(),
]],
]));
$response->assertSessionHasNoErrors();
});
test('product variant name at max length 200 is accepted', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->post(route('admin.master.products.store'), makeValidProductPayload([
'variants' => [[
'name' => str_repeat('a', 200),
'stock' => 10,
'reject_stock' => 0,
'retail_stock' => 0,
'photo_key' => 'product-variant/maxlen.jpg',
'prices' => allPriceTypes(),
]],
]));
$response->assertSessionHasNoErrors();
});
test('product variant name over 200 characters fails validation', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->post(route('admin.master.products.store'), makeValidProductPayload([
'variants' => [[
'name' => str_repeat('a', 201),
'stock' => 10,
'reject_stock' => 0,
'retail_stock' => 0,
'photo_key' => 'product-variant/overmax.jpg',
'prices' => allPriceTypes(),
]],
]));
$response->assertSessionHasErrors('variants.0.name');
});
test('product stock at very large value is accepted', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->post(route('admin.master.products.store'), makeValidProductPayload([
'variants' => [[
'name' => 'Varian Besar',
'stock' => 2147483647,
'reject_stock' => 2147483647,
'retail_stock' => 2147483647,
'photo_key' => 'product-variant/huge.jpg',
'prices' => allPriceTypes(),
]],
]));
$response->assertSessionHasNoErrors();
});
test('product variant name with only whitespace is rejected', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->post(route('admin.master.products.store'), makeValidProductPayload([
'variants' => [[
'name' => ' ',
'stock' => 10,
'reject_stock' => 0,
'retail_stock' => 0,
'photo_key' => 'product-variant/whitespace.jpg',
'prices' => allPriceTypes(),
]],
]));
$response->assertSessionHasErrors('variants.0.name');
});
test('product with description containing newlines is accepted', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->post(route('admin.master.products.store'), makeValidProductPayload([
'description' => "Baris 1\nBaris 2\nBaris 3",
]));
$response->assertSessionHasNoErrors();
});
test('product with very long description is accepted', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->post(route('admin.master.products.store'), makeValidProductPayload([
'description' => str_repeat('Deskripsi produk panjang. ', 100),
]));
$response->assertSessionHasNoErrors();
});
test('product with mixed special characters in name is accepted', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->post(route('admin.master.products.store'), makeValidProductPayload([
'name' => 'A & B (v2.0) - Best Product!',
]));
$response->assertSessionHasNoErrors();
$this->assertDatabaseHas('products', ['slug' => 'a-b-v20-best-product']);
});
test('product name with leading and trailing spaces is accepted', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->post(route('admin.master.products.store'), makeValidProductPayload([
'name' => ' Produk Spasi ',
]));
$response->assertSessionHasNoErrors();
});
test('product name with multiple spaces generates correct slug', function () {
$user = User::factory()->create();
$this->actingAs($user);
$this->post(route('admin.master.products.store'), makeValidProductPayload([
'name' => 'Produk dengan spasi banyak',
]));
$this->assertDatabaseHas('products', [
'name' => 'Produk dengan spasi banyak',
'slug' => 'produk-dengan-spasi-banyak',
]);
});
test('product name with newline characters is accepted', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->post(route('admin.master.products.store'), makeValidProductPayload([
'name' => "Produk\nBaru",
]));
$response->assertSessionHasNoErrors();
});
test('product name with curly braces is accepted', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->post(route('admin.master.products.store'), makeValidProductPayload([
'name' => '{code} Product',
]));
$response->assertSessionHasNoErrors();
});
test('product name with angle brackets is accepted', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->post(route('admin.master.products.store'), makeValidProductPayload([
'name' => ' Name',
]));
$response->assertSessionHasNoErrors();
});
test('product name with pipe symbol is accepted', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->post(route('admin.master.products.store'), makeValidProductPayload([
'name' => 'A|B Product',
]));
$response->assertSessionHasNoErrors();
});
test('product name with semicolon is accepted', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->post(route('admin.master.products.store'), makeValidProductPayload([
'name' => 'Product;Injected',
]));
$response->assertSessionHasNoErrors();
});
test('product name with backtick is accepted', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->post(route('admin.master.products.store'), makeValidProductPayload([
'name' => '`Code` Product',
]));
$response->assertSessionHasNoErrors();
});
test('product name with date-like pattern is accepted', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->post(route('admin.master.products.store'), makeValidProductPayload([
'name' => 'Product 2024-01-15',
]));
$response->assertSessionHasNoErrors();
});
test('shared_price with string price containing dots is rejected as not integer', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->post(route('admin.master.products.store'), makeValidSharedPricePayload([
'shared_prices' => array_map(fn($p) => ['type' => $p['type'], 'price' => '15.000'], allPriceTypes()),
]));
$response->assertSessionHasErrors('shared_prices.0.price');
});
test('shared_price with string price with thousands separator is rejected as not integer', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->post(route('admin.master.products.store'), makeValidSharedPricePayload([
'shared_prices' => array_map(fn($p) => ['type' => $p['type'], 'price' => '1.250.000'], allPriceTypes()),
]));
$response->assertSessionHasErrors('shared_prices.0.price');
});
/*
|--------------------------------------------------------------------------
| DATA INTEGRITY
|--------------------------------------------------------------------------
*/
test('created product has correct timestamps', function () {
$user = User::factory()->create();
$this->actingAs($user);
$this->post(route('admin.master.products.store'), makeValidProductPayload());
$product = Product::where('name', 'Produk Test')->first();
expect($product->created_at)->not->toBeNull();
expect($product->updated_at)->not->toBeNull();
});
test('product factory creates valid product', function () {
$product = Product::factory()->create();
expect($product->name)->not->toBeEmpty();
expect($product->slug)->not->toBeEmpty();
expect($product->id)->toBeInt();
});
test('product price is stored as integer', function () {
$user = User::factory()->create();
$this->actingAs($user);
$this->post(route('admin.master.products.store'), makeValidProductPayload());
$price = ProductPrice::first();
expect($price->price)->toBeInt();
});
test('product variant has correct relationships', function () {
$product = Product::factory()->create();
$variant = ProductVariant::factory()->create(['product_id' => $product->id]);
expect($variant->product->id)->toBe($product->id);
expect($variant->productPrices)->toHaveCount(0);
});
test('product price belongs to variant', function () {
$variant = ProductVariant::factory()->create();
$price = ProductPrice::factory()->create(['variant_id' => $variant->id, 'type' => 'retail', 'price' => 15000]);
expect($price->variant->id)->toBe($variant->id);
expect($price->type->value)->toBe('retail');
expect($price->type_label)->toBe('Ecer');
});
test('product categories are synced correctly on create', function () {
$user = User::factory()->create();
$this->actingAs($user);
$cat1 = Category::factory()->create();
$cat2 = Category::factory()->create();
$this->post(route('admin.master.products.store'), makeValidProductPayload([
'category_ids' => [$cat1->id, $cat2->id],
]));
$product = Product::where('name', 'Produk Test')->first();
$this->assertCount(2, $product->categories);
});
/*
|--------------------------------------------------------------------------
| REALISTIC USER SCENARIOS
|--------------------------------------------------------------------------
*/
test('user creates product then immediately edits it', function () {
$user = User::factory()->create();
$this->actingAs($user);
$this->post(route('admin.master.products.store'), makeValidProductPayload(['name' => 'Draft Product']));
$product = Product::where('name', 'Draft Product')->first();
$category = $product->categories->first();
$variant = $product->productVariants->first();
$this->put(route('admin.master.products.update', $product), makeValidProductPayload([
'name' => 'Final Product',
'category_ids' => [$category->id],
'variants' => [[
'id' => $variant->id,
'name' => $variant->name,
'stock' => $variant->stock,
'reject_stock' => $variant->reject_stock,
'retail_stock' => $variant->retail_stock,
'photo_key' => 'product-variant/final.jpg',
'prices' => allPriceTypes(),
]],
]));
$product->refresh();
expect($product->name)->toBe('Final Product');
});
test('user creates multiple products and deletes one', function () {
$user = User::factory()->create();
$this->actingAs($user);
$this->post(route('admin.master.products.store'), makeValidProductPayload(['name' => 'Keep']));
$this->post(route('admin.master.products.store'), makeValidProductPayload(['name' => 'Delete Me']));
$this->post(route('admin.master.products.store'), makeValidProductPayload(['name' => 'Also Keep']));
$toDelete = Product::where('name', 'Delete Me')->first();
$this->delete(route('admin.master.products.destroy', $toDelete));
$response = $this->get(route('admin.master.products.index'));
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->component('admin/master/product/index')
->has('products', 2)
);
});
test('user toggles product status multiple times', function () {
$user = User::factory()->create();
$this->actingAs($user);
$product = Product::factory()->create(['status' => 'active']);
$this->post(route('admin.master.products.toggle-status', $product));
$product->refresh();
expect($product->status->value)->toBe('inactive');
$this->post(route('admin.master.products.toggle-status', $product));
$product->refresh();
expect($product->status->value)->toBe('active');
$this->post(route('admin.master.products.toggle-status', $product));
$product->refresh();
expect($product->status->value)->toBe('inactive');
});
test('user creates product with 10 variants each with 9 prices', function () {
$user = User::factory()->create();
$this->actingAs($user);
$variants = [];
for ($i = 0; $i < 10; $i++) {
$variants[] = [
'name' => "Varian {$i}",
'stock' => $i * 10,
'reject_stock' => $i,
'retail_stock' => $i * 2,
'photo_key' => "product-variant/v{$i}.jpg",
'prices' => allPriceTypes(),
];
}
$response = $this->post(route('admin.master.products.store'), makeValidProductPayload([
'variants' => $variants,
]));
$response->assertSessionHasNoErrors();
$this->assertDatabaseCount('product_variants', 10);
$this->assertDatabaseCount('product_prices', 90);
});
test('user creates product then deletes it and creates another with same name', function () {
$user = User::factory()->create();
$this->actingAs($user);
$this->post(route('admin.master.products.store'), makeValidProductPayload(['name' => 'Produk']));
$product = Product::where('name', 'Produk')->first();
$this->delete(route('admin.master.products.destroy', $product));
$response = $this->post(route('admin.master.products.store'), makeValidProductPayload(['name' => 'Produk']));
$response->assertSessionHasNoErrors();
$this->assertDatabaseCount('products', 2);
});
test('user submits empty form and gets all required errors', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->post(route('admin.master.products.store'), []);
$response->assertSessionHasErrors(['name', 'category_ids', 'variants']);
});
test('user creates product with very long description containing special chars', function () {
$user = User::factory()->create();
$this->actingAs($user);
$description = str_repeat('Produk & Co. (PT) - Best! Bold ', 50);
$trimmed = trim($description);
$response = $this->post(route('admin.master.products.store'), makeValidProductPayload([
'description' => $description,
]));
$response->assertSessionHasNoErrors();
$product = Product::where('name', 'Produk Test')->first();
expect($product->description)->toBe($trimmed);
});
test('index page works with products that have many variants', function () {
$user = User::factory()->create();
$this->actingAs($user);
$product = Product::factory()->create();
ProductVariant::factory()->count(20)->create(['product_id' => $product->id]);
$response = $this->get(route('admin.master.products.index'));
$response->assertOk();
});
test('delete product does not affect other products', function () {
$user = User::factory()->create();
$this->actingAs($user);
$product1 = Product::factory()->create(['name' => 'Product 1']);
$product2 = Product::factory()->create(['name' => 'Product 2']);
$variant1 = ProductVariant::factory()->create(['product_id' => $product1->id]);
$variant2 = ProductVariant::factory()->create(['product_id' => $product2->id]);
ProductPrice::factory()->create(['variant_id' => $variant1->id, 'type' => 'retail', 'price' => 10000]);
ProductPrice::factory()->create(['variant_id' => $variant2->id, 'type' => 'retail', 'price' => 20000]);
$this->delete(route('admin.master.products.destroy', $product1));
$this->assertSoftDeleted('products', ['id' => $product1->id]);
$this->assertDatabaseHas('products', ['id' => $product2->id, 'deleted_at' => null]);
$this->assertDatabaseCount('product_prices', 1);
$this->assertDatabaseHas('product_prices', ['variant_id' => $variant2->id, 'price' => 20000]);
});
test('user can create product with name that is a number string', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->post(route('admin.master.products.store'), makeValidProductPayload([
'name' => '007',
]));
$response->assertSessionHasNoErrors();
$this->assertDatabaseHas('products', ['name' => '007', 'slug' => '007']);
});
test('user rapidly creates two products with same name', function () {
$user = User::factory()->create();
$this->actingAs($user);
$this->post(route('admin.master.products.store'), makeValidProductPayload(['name' => 'Rapid Product']));
$response = $this->post(route('admin.master.products.store'), makeValidProductPayload(['name' => 'Rapid Product']));
$response->assertSessionHasNoErrors();
$this->assertDatabaseCount('products', 2);
});
test('product can be created with all price types having different values', function () {
$user = User::factory()->create();
$this->actingAs($user);
$prices = [
['type' => 'distributor', 'price' => 10000],
['type' => 'agent', 'price' => 11000],
['type' => 'sub_agent', 'price' => 12000],
['type' => 'wholesale', 'price' => 13000],
['type' => 'retail', 'price' => 15000],
['type' => 'tiktok', 'price' => 16000],
['type' => 'shopee', 'price' => 16500],
['type' => 'capital', 'price' => 8000],
['type' => 'reject', 'price' => 5000],
];
$response = $this->post(route('admin.master.products.store'), makeValidProductPayload([
'variants' => [[
'name' => 'Semua Harga',
'stock' => 100,
'reject_stock' => 10,
'retail_stock' => 20,
'photo_key' => 'product-variant/all-prices.jpg',
'prices' => $prices,
]],
]));
$response->assertSessionHasNoErrors();
$variant = ProductVariant::where('name', 'Semua Harga')->first();
$dbPrices = ProductPrice::where('variant_id', $variant->id)->get();
expect($dbPrices)->toHaveCount(9);
expect($dbPrices->firstWhere('type', 'distributor')->price)->toBe(10000);
expect($dbPrices->firstWhere('type', 'reject')->price)->toBe(5000);
});
test('shared price applies to all variants', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->post(route('admin.master.products.store'), makeValidSharedPricePayload([
'variants' => [
['name' => 'V1', 'stock' => 10, 'reject_stock' => 0, 'retail_stock' => 0, 'photo_key' => 'v1.jpg', 'prices' => []],
['name' => 'V2', 'stock' => 20, 'reject_stock' => 0, 'retail_stock' => 0, 'photo_key' => 'v2.jpg', 'prices' => []],
],
]));
$response->assertSessionHasNoErrors();
$this->assertDatabaseCount('product_variants', 2);
$this->assertDatabaseCount('product_prices', 18);
$v1 = ProductVariant::where('name', 'V1')->first();
$v2 = ProductVariant::where('name', 'V2')->first();
$v1Prices = ProductPrice::where('variant_id', $v1->id)->pluck('price')->toArray();
$v2Prices = ProductPrice::where('variant_id', $v2->id)->pluck('price')->toArray();
expect($v1Prices)->toEqual($v2Prices);
});
test('user tries to store with invalid use_same_price boolean', function () {
$user = User::factory()->create();
$this->actingAs($user);
$payload = makeValidProductPayload();
$payload['use_same_price'] = 'maybe';
$response = $this->post(route('admin.master.products.store'), $payload);
// 'maybe' is truthy in PHP so use_same_price becomes true
// but shared_prices is empty, so it should fail
$response->assertSessionHasErrors('shared_prices');
});