dstpabuaran.com/tests/Feature/Admin/Master/ProductTest.php
Yoga Pangestu f562f2babe Refactor SupplierTest to use permission setup function and enhance phone number validation tests
- Introduced `giveSupplierPermissions` function to streamline user permission setup for supplier tests.
- Updated all supplier-related tests to utilize the new permission setup function.
- Enhanced phone number validation tests to accept various formats and special characters.
- Removed redundant tests related to unsupported phone number formats.
- Consolidated tests for supplier creation and updates to improve readability and maintainability.
2026-08-22 13:46:22 +07:00

2774 lines
95 KiB
PHP

<?php
use App\Models\Category;
use App\Models\Product;
use App\Models\ProductPrice;
use App\Models\ProductVariant;
use App\Models\StockMutation;
use App\Models\User;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Inertia\Testing\AssertableInertia as Assert;
uses(RefreshDatabase::class);
function giveProductPermissions(): User
{
$seeder = new RolePermissionSeeder;
$seeder->run();
$user = User::factory()->create();
$user->givePermissionTo([
'products.view',
'products.create',
'products.update',
'products.delete',
]);
return $user;
}
/*
|--------------------------------------------------------------------------
| HELPERS
|--------------------------------------------------------------------------
*/
function makeValidProductPayload(array $overrides = []): array
{
$category = Category::factory()->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_keys' => ['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_capital', 'price' => 5000],
['type' => 'reject_selling', 'price' => 6000],
],
],
],
], $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_capital', 'price' => 5000],
['type' => 'reject_selling', 'price' => 6000],
],
'variants' => [
[
'name' => 'Varian Shared',
'stock' => 50,
'reject_stock' => 5,
'retail_stock' => 10,
'photo_keys' => ['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_capital', 'price' => 5000],
['type' => 'reject_selling', 'price' => 6000],
];
}
/*
|--------------------------------------------------------------------------
| 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 = giveProductPermissions();
$this->actingAs($user);
$response = $this->get(route('admin.master.products.index'));
$response->assertOk();
});
test('authenticated users can visit the product create page', function () {
$user = giveProductPermissions();
$this->actingAs($user);
$response = $this->get(route('admin.master.products.create'));
$response->assertOk();
});
test('authenticated users can visit the product edit page', function () {
$user = giveProductPermissions();
$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 = giveProductPermissions();
$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.data', 3)
->where('products.total', 3)
->where('products.current_page', 1)
->where('products.per_page', 25)
);
});
test('index page works with zero products', function () {
$user = giveProductPermissions();
$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.data', 0)
->where('products.total', 0)
->where('products.current_page', 1)
->where('products.per_page', 25)
);
});
test('index page does not display soft-deleted products', function () {
$user = giveProductPermissions();
$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.data', 1)
->where('products.total', 1)
->where('products.current_page', 1)
->where('products.per_page', 25)
->where('products.data.0.name', 'Active Product')
);
});
test('index page displays correct count after delete', function () {
$user = giveProductPermissions();
$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.data', 4)
->where('products.total', 4)
->where('products.current_page', 1)
->where('products.per_page', 25)
);
});
test('index page includes product categories', function () {
$user = giveProductPermissions();
$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.data.0.categories', 1)
);
});
test('index page includes product variants', function () {
$user = giveProductPermissions();
$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.data.0.product_variants', 1)
);
});
test('index page includes product variant prices', function () {
$user = giveProductPermissions();
$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.data.0.product_variants.0.product_prices', 1)
);
});
/*
|--------------------------------------------------------------------------
| CREATE / STORE - BASIC
|--------------------------------------------------------------------------
*/
test('product can be created with per-variant prices', function () {
$user = giveProductPermissions();
$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 = giveProductPermissions();
$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 = giveProductPermissions();
$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 = giveProductPermissions();
$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 = giveProductPermissions();
$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_keys' => ['product-variant/v1.jpg'],
'prices' => allPriceTypes(),
],
[
'name' => 'Varian 2',
'stock' => 20,
'reject_stock' => 2,
'retail_stock' => 4,
'photo_keys' => ['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 = giveProductPermissions();
$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_keys' => ['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 = giveProductPermissions();
$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_keys' => ['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 = giveProductPermissions();
$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_keys' => ['product-variant/empty.jpg'],
'prices' => allPriceTypes(),
]],
]));
$response->assertSessionHasNoErrors();
});
test('product can be created with default status when not provided', function () {
$user = giveProductPermissions();
$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 = giveProductPermissions();
$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 = giveProductPermissions();
$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 = giveProductPermissions();
$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 = giveProductPermissions();
$this->actingAs($user);
$response = $this->post(route('admin.master.products.store'), makeValidProductPayload());
$response->assertRedirect();
});
test('store creates new product in database', function () {
$user = giveProductPermissions();
$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 = giveProductPermissions();
$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 = giveProductPermissions();
$this->actingAs($user);
$response = $this->post(route('admin.master.products.store'), makeValidProductPayload(['name' => '']));
$response->assertSessionHasErrors('name');
});
test('product name must be string', function () {
$user = giveProductPermissions();
$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 = giveProductPermissions();
$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 = giveProductPermissions();
$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 = giveProductPermissions();
$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 = giveProductPermissions();
$this->actingAs($user);
$response = $this->post(route('admin.master.products.store'), makeValidProductPayload(['name' => ' ']));
$response->assertSessionHasErrors('name');
});
test('product category_ids is required', function () {
$user = giveProductPermissions();
$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 = giveProductPermissions();
$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 = giveProductPermissions();
$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 = giveProductPermissions();
$this->actingAs($user);
$response = $this->post(route('admin.master.products.store'), makeValidProductPayload(['variants' => []]));
$response->assertSessionHasErrors('variants');
});
test('product variants must be array', function () {
$user = giveProductPermissions();
$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 = giveProductPermissions();
$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 = giveProductPermissions();
$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 = giveProductPermissions();
$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 = giveProductPermissions();
$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 = giveProductPermissions();
$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 = giveProductPermissions();
$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_keys is required', function () {
$user = giveProductPermissions();
$this->actingAs($user);
$payload = makeValidProductPayload();
unset($payload['variants'][0]['photo_keys']);
$response = $this->post(route('admin.master.products.store'), $payload);
$response->assertSessionHasErrors('variants.0.photo_keys');
});
/*
|--------------------------------------------------------------------------
| STORE VALIDATION - STATUS
|--------------------------------------------------------------------------
*/
test('product status must be valid enum value', function () {
$user = giveProductPermissions();
$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 = giveProductPermissions();
$this->actingAs($user);
$response = $this->post(route('admin.master.products.store'), makeValidProductPayload(['status' => 'active']));
$response->assertSessionHasNoErrors();
});
test('product status accepts inactive', function () {
$user = giveProductPermissions();
$this->actingAs($user);
$response = $this->post(route('admin.master.products.store'), makeValidProductPayload(['status' => 'inactive']));
$response->assertSessionHasNoErrors();
});
test('product status accepts draft', function () {
$user = giveProductPermissions();
$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 = giveProductPermissions();
$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 = giveProductPermissions();
$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 = giveProductPermissions();
$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 = giveProductPermissions();
$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 = giveProductPermissions();
$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 = giveProductPermissions();
$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 = giveProductPermissions();
$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 = giveProductPermissions();
$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 = giveProductPermissions();
$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 = giveProductPermissions();
$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 = giveProductPermissions();
$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_keys' => ['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 = giveProductPermissions();
$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 = giveProductPermissions();
$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 = giveProductPermissions();
$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 = giveProductPermissions();
$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_keys' => ['product-variant/v1.jpg'],
'prices' => allPriceTypes(),
]],
]));
$product->refresh();
expect($product->status->value)->toBe('draft');
});
test('update can change categories', function () {
$user = giveProductPermissions();
$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_keys' => ['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 = giveProductPermissions();
$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_keys' => ['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 = giveProductPermissions();
$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_keys' => ['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 = giveProductPermissions();
$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 = giveProductPermissions();
$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 = giveProductPermissions();
$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 = giveProductPermissions();
$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 = giveProductPermissions();
$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 = giveProductPermissions();
$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 = giveProductPermissions();
$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 = giveProductPermissions();
$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 = giveProductPermissions();
$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 = giveProductPermissions();
$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 = giveProductPermissions();
$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 = giveProductPermissions();
$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 = giveProductPermissions();
$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 = giveProductPermissions();
$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 = giveProductPermissions();
$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 = giveProductPermissions();
$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 = giveProductPermissions();
$this->actingAs($user);
$response = $this->post(route('admin.master.products.store'), makeValidProductPayload([
'name' => '<script>alert("xss")</script>',
]));
$response->assertSessionHasNoErrors();
$this->assertDatabaseHas('products', ['name' => '<script>alert("xss")</script>']);
});
test('product name with only numbers is accepted', function () {
$user = giveProductPermissions();
$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 = giveProductPermissions();
$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 = giveProductPermissions();
$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 = giveProductPermissions();
$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 = giveProductPermissions();
$this->actingAs($user);
$response = $this->post(route('admin.master.products.store'), makeValidProductPayload([
'variants' => [[
'name' => '<b>Size</b> XL (🔥)',
'stock' => 10,
'reject_stock' => 0,
'retail_stock' => 0,
'photo_keys' => ['product-variant/weird.jpg'],
'prices' => allPriceTypes(),
]],
]));
$response->assertSessionHasNoErrors();
});
test('product variant name at max length 200 is accepted', function () {
$user = giveProductPermissions();
$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_keys' => ['product-variant/maxlen.jpg'],
'prices' => allPriceTypes(),
]],
]));
$response->assertSessionHasNoErrors();
});
test('product variant name over 200 characters fails validation', function () {
$user = giveProductPermissions();
$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_keys' => ['product-variant/overmax.jpg'],
'prices' => allPriceTypes(),
]],
]));
$response->assertSessionHasErrors('variants.0.name');
});
test('product stock at very large value is accepted', function () {
$user = giveProductPermissions();
$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_keys' => ['product-variant/huge.jpg'],
'prices' => allPriceTypes(),
]],
]));
$response->assertSessionHasNoErrors();
});
test('product variant name with only whitespace is rejected', function () {
$user = giveProductPermissions();
$this->actingAs($user);
$response = $this->post(route('admin.master.products.store'), makeValidProductPayload([
'variants' => [[
'name' => ' ',
'stock' => 10,
'reject_stock' => 0,
'retail_stock' => 0,
'photo_keys' => ['product-variant/whitespace.jpg'],
'prices' => allPriceTypes(),
]],
]));
$response->assertSessionHasErrors('variants.0.name');
});
test('product with description containing newlines is accepted', function () {
$user = giveProductPermissions();
$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 = giveProductPermissions();
$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 = giveProductPermissions();
$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 = giveProductPermissions();
$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 = giveProductPermissions();
$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 = giveProductPermissions();
$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 = giveProductPermissions();
$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 = giveProductPermissions();
$this->actingAs($user);
$response = $this->post(route('admin.master.products.store'), makeValidProductPayload([
'name' => '<Product> Name',
]));
$response->assertSessionHasNoErrors();
});
test('product name with pipe symbol is accepted', function () {
$user = giveProductPermissions();
$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 = giveProductPermissions();
$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 = giveProductPermissions();
$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 = giveProductPermissions();
$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 = giveProductPermissions();
$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 = giveProductPermissions();
$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 = giveProductPermissions();
$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 = giveProductPermissions();
$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 = giveProductPermissions();
$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 = giveProductPermissions();
$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_keys' => ['product-variant/final.jpg'],
'prices' => allPriceTypes(),
]],
]));
$product->refresh();
expect($product->name)->toBe('Final Product');
});
test('user creates multiple products and deletes one', function () {
$user = giveProductPermissions();
$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.data', 2)
->where('products.total', 2)
->where('products.current_page', 1)
->where('products.per_page', 25)
);
});
test('user toggles product status multiple times', function () {
$user = giveProductPermissions();
$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 = giveProductPermissions();
$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_keys' => ["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 = giveProductPermissions();
$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 = giveProductPermissions();
$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 = giveProductPermissions();
$this->actingAs($user);
$description = str_repeat('Produk & Co. (PT) - Best! <b>Bold</b> ', 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 = giveProductPermissions();
$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 = giveProductPermissions();
$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 = giveProductPermissions();
$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 = giveProductPermissions();
$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 = giveProductPermissions();
$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_capital', 'price' => 5000],
['type' => 'reject_selling', 'price' => 6000],
];
$response = $this->post(route('admin.master.products.store'), makeValidProductPayload([
'variants' => [[
'name' => 'Semua Harga',
'stock' => 100,
'reject_stock' => 10,
'retail_stock' => 20,
'photo_keys' => ['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(10);
expect($dbPrices->firstWhere('type', 'distributor')->price)->toBe(10000);
expect($dbPrices->firstWhere('type', 'reject_capital')->price)->toBe(5000);
expect($dbPrices->firstWhere('type', 'reject_selling')->price)->toBe(6000);
});
test('shared price applies to all variants', function () {
$user = giveProductPermissions();
$this->actingAs($user);
$response = $this->post(route('admin.master.products.store'), makeValidSharedPricePayload([
'variants' => [
['name' => 'V1', 'stock' => 10, 'reject_stock' => 0, 'retail_stock' => 0, 'photo_keys' => ['v1.jpg'], 'prices' => []],
['name' => 'V2', 'stock' => 20, 'reject_stock' => 0, 'retail_stock' => 0, 'photo_keys' => ['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 = giveProductPermissions();
$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');
});
/*
|--------------------------------------------------------------------------
| PRODUCT VARIANT - EDIT PAGE
|--------------------------------------------------------------------------
*/
test('guest cannot access variant edit page', function () {
$product = Product::factory()->create();
$variant = ProductVariant::factory()->for($product)->create();
$response = $this->get(route('admin.master.products.variants.edit', [$product, $variant]));
$response->assertRedirect(route('login'));
});
test('authenticated user can access variant edit page', function () {
$user = giveProductPermissions();
$this->actingAs($user);
$product = Product::factory()->create();
$variant = ProductVariant::factory()->for($product)->create();
$response = $this->get(route('admin.master.products.variants.edit', [$product, $variant]));
$response->assertStatus(200);
$response->assertInertia(fn (Assert $page) => $page
->component('admin/master/product/variant/edit')
->has('variant')
);
});
test('variant edit page shows variant data', function () {
$user = giveProductPermissions();
$this->actingAs($user);
$product = Product::factory()->create();
$variant = ProductVariant::factory()->for($product)->create([
'name' => 'Varian Edit Test',
'stock' => 100,
'reject_stock' => 10,
'retail_stock' => 20,
]);
$response = $this->get(route('admin.master.products.variants.edit', [$product, $variant]));
$response->assertInertia(fn (Assert $page) => $page
->component('admin/master/product/variant/edit')
->where('variant.id', $variant->id)
->where('variant.product_id', $product->id)
->where('variant.name', 'Varian Edit Test')
->where('variant.stock', 100)
->where('variant.reject_stock', 10)
->where('variant.retail_stock', 20)
);
});
/*
|--------------------------------------------------------------------------
| PRODUCT VARIANT - UPDATE
|--------------------------------------------------------------------------
*/
test('guest cannot update variant', function () {
$product = Product::factory()->create();
$variant = ProductVariant::factory()->for($product)->create();
$response = $this->put(route('admin.master.products.variants.update', [$product, $variant]), [
'name' => 'Updated',
'stock' => 50,
'reject_stock' => 5,
'retail_stock' => 10,
'photo_keys' => ['product-variant/updated.jpg'],
'prices' => allPriceTypes(),
]);
$response->assertRedirect(route('login'));
});
test('variant can be updated', function () {
$user = giveProductPermissions();
$this->actingAs($user);
$product = Product::factory()->create();
$variant = ProductVariant::factory()->for($product)->create([
'name' => 'Original Name',
'stock' => 100,
]);
$response = $this->put(route('admin.master.products.variants.update', [$product, $variant]), [
'name' => 'Updated Name',
'stock' => 200,
'reject_stock' => 15,
'retail_stock' => 25,
'photo_keys' => ['product-variant/new-photo.jpg'],
'prices' => allPriceTypes(),
]);
$response->assertRedirect();
$this->assertDatabaseHas('product_variants', [
'id' => $variant->id,
'name' => 'Updated Name',
'stock' => 200,
'reject_stock' => 15,
'retail_stock' => 25,
]);
});
test('variant update replaces old prices', function () {
$user = giveProductPermissions();
$this->actingAs($user);
$product = Product::factory()->create();
$variant = ProductVariant::factory()->for($product)->create();
ProductPrice::create([
'variant_id' => $variant->id,
'type' => 'retail',
'price' => 10000,
]);
$this->assertDatabaseCount('product_prices', 1);
$response = $this->put(route('admin.master.products.variants.update', [$product, $variant]), [
'name' => $variant->name,
'stock' => $variant->stock,
'reject_stock' => $variant->reject_stock,
'retail_stock' => $variant->retail_stock,
'photo_keys' => ['product-variant/test.jpg'],
'prices' => allPriceTypes(),
]);
$response->assertRedirect();
$this->assertDatabaseCount('product_prices', 9);
});
test('variant update name is required', function () {
$user = giveProductPermissions();
$this->actingAs($user);
$product = Product::factory()->create();
$variant = ProductVariant::factory()->for($product)->create();
$response = $this->put(route('admin.master.products.variants.update', [$product, $variant]), [
'name' => '',
'stock' => 100,
'reject_stock' => 10,
'retail_stock' => 20,
'photo_keys' => ['product-variant/test.jpg'],
'prices' => allPriceTypes(),
]);
$response->assertSessionHasErrors('name');
});
test('variant update requires exactly 9 prices', function () {
$user = giveProductPermissions();
$this->actingAs($user);
$product = Product::factory()->create();
$variant = ProductVariant::factory()->for($product)->create();
$response = $this->put(route('admin.master.products.variants.update', [$product, $variant]), [
'name' => 'Test',
'stock' => 100,
'reject_stock' => 10,
'retail_stock' => 20,
'photo_keys' => ['product-variant/test.jpg'],
'prices' => [
['type' => 'retail', 'price' => 10000],
],
]);
$response->assertSessionHasErrors('prices');
});
/*
|--------------------------------------------------------------------------
| PRODUCT VARIANT - DELETE
|--------------------------------------------------------------------------
*/
test('guest cannot delete variant', function () {
$product = Product::factory()->create();
$variant = ProductVariant::factory()->for($product)->create();
$response = $this->delete(route('admin.master.products.variants.destroy', [$product, $variant]));
$response->assertRedirect(route('login'));
$this->assertDatabaseHas('product_variants', ['id' => $variant->id, 'deleted_at' => null]);
});
test('variant can be deleted', function () {
$user = giveProductPermissions();
$this->actingAs($user);
$product = Product::factory()->create();
$variant = ProductVariant::factory()->for($product)->create();
$response = $this->delete(route('admin.master.products.variants.destroy', [$product, $variant]));
$response->assertRedirect();
$this->assertSoftDeleted('product_variants', ['id' => $variant->id]);
});
test('delete variant cascades to product prices', function () {
$user = giveProductPermissions();
$this->actingAs($user);
$product = Product::factory()->create();
$variant = ProductVariant::factory()->for($product)->create();
ProductPrice::create([
'variant_id' => $variant->id,
'type' => 'retail',
'price' => 10000,
]);
ProductPrice::create([
'variant_id' => $variant->id,
'type' => 'wholesale',
'price' => 8000,
]);
$this->assertDatabaseCount('product_prices', 2);
$response = $this->delete(route('admin.master.products.variants.destroy', [$product, $variant]));
$response->assertRedirect();
$this->assertDatabaseCount('product_prices', 0);
});
test('delete variant does not affect other variants', function () {
$user = giveProductPermissions();
$this->actingAs($user);
$product = Product::factory()->create();
$variant1 = ProductVariant::factory()->for($product)->create(['name' => 'Keep']);
$variant2 = ProductVariant::factory()->for($product)->create(['name' => 'Delete']);
ProductPrice::create([
'variant_id' => $variant1->id,
'type' => 'retail',
'price' => 10000,
]);
ProductPrice::create([
'variant_id' => $variant2->id,
'type' => 'retail',
'price' => 15000,
]);
$response = $this->delete(route('admin.master.products.variants.destroy', [$product, $variant2]));
$response->assertRedirect();
$this->assertDatabaseHas('product_variants', ['id' => $variant1->id, 'name' => 'Keep', 'deleted_at' => null]);
$this->assertDatabaseHas('product_prices', ['variant_id' => $variant1->id]);
});
test('deleting non-existent variant returns 404', function () {
$user = giveProductPermissions();
$this->actingAs($user);
$product = Product::factory()->create();
$response = $this->delete(route('admin.master.products.variants.destroy', [$product, 99999]));
$response->assertStatus(404);
});
/*
|--------------------------------------------------------------------------
| TRANSFER STOCK (BAGUS -> ECER)
|--------------------------------------------------------------------------
*/
test('guest cannot transfer stock', function () {
$product = Product::factory()->create();
$variant = ProductVariant::factory()->for($product)->create(['stock' => 100]);
$response = $this->post(route('admin.master.products.variants.transfer-stock', [$product, $variant]), [
'quantity' => 10,
]);
$response->assertRedirect(route('login'));
});
test('authenticated user can transfer stock', function () {
$user = giveProductPermissions();
$this->actingAs($user);
$product = Product::factory()->create();
$variant = ProductVariant::factory()->for($product)->create([
'stock' => 100,
'retail_stock' => 20,
]);
$response = $this->post(route('admin.master.products.variants.transfer-stock', [$product, $variant]), [
'quantity' => 10,
]);
$response->assertRedirect();
$variant->refresh();
expect($variant->stock)->toBe(90);
expect($variant->retail_stock)->toBe(30);
});
test('transfer stock creates two stock mutation records', function () {
$user = giveProductPermissions();
$this->actingAs($user);
$product = Product::factory()->create();
$variant = ProductVariant::factory()->for($product)->create(['stock' => 100, 'retail_stock' => 20]);
$this->post(route('admin.master.products.variants.transfer-stock', [$product, $variant]), [
'quantity' => 10,
]);
$this->assertDatabaseCount('stock_mutations', 2);
$outMutation = StockMutation::where('stockable_id', $variant->id)->where('type', 'out')->first();
expect($outMutation)->not->toBeNull();
expect((float) $outMutation->quantity)->toBe(-10.0);
expect((float) $outMutation->stock_before)->toBe(100.0);
expect((float) $outMutation->stock_after)->toBe(90.0);
expect($outMutation->stock_quality)->toBe('good');
expect($outMutation->user_id)->toBe($user->id);
$inMutation = StockMutation::where('stockable_id', $variant->id)->where('type', 'in')->first();
expect($inMutation)->not->toBeNull();
expect((float) $inMutation->quantity)->toBe(10.0);
expect((float) $inMutation->stock_before)->toBe(20.0);
expect((float) $inMutation->stock_after)->toBe(30.0);
expect($inMutation->stock_quality)->toBe('retail');
expect($inMutation->user_id)->toBe($user->id);
});
test('transfer stock with description saves to mutations', function () {
$user = giveProductPermissions();
$this->actingAs($user);
$product = Product::factory()->create();
$variant = ProductVariant::factory()->for($product)->create(['stock' => 50, 'retail_stock' => 10]);
$this->post(route('admin.master.products.variants.transfer-stock', [$product, $variant]), [
'quantity' => 5,
'description' => 'Transfer untuk display toko',
]);
$mutation = StockMutation::where('stockable_id', $variant->id)->where('type', 'out')->first();
expect($mutation->description)->toBe('Transfer untuk display toko');
});
test('transfer stock without description uses default', function () {
$user = giveProductPermissions();
$this->actingAs($user);
$product = Product::factory()->create();
$variant = ProductVariant::factory()->for($product)->create(['stock' => 50, 'retail_stock' => 10]);
$this->post(route('admin.master.products.variants.transfer-stock', [$product, $variant]), [
'quantity' => 5,
]);
$mutation = StockMutation::where('stockable_id', $variant->id)->where('type', 'out')->first();
expect($mutation->description)->toBe('Transfer stok bagus ke stok ecer');
});
test('transfer stock quantity must be provided', function () {
$user = giveProductPermissions();
$this->actingAs($user);
$product = Product::factory()->create();
$variant = ProductVariant::factory()->for($product)->create(['stock' => 100]);
$response = $this->post(route('admin.master.products.variants.transfer-stock', [$product, $variant]), []);
$variant->refresh();
expect($variant->stock)->toBe(100);
$this->assertDatabaseCount('stock_mutations', 0);
});
test('transfer stock quantity must be integer', function () {
$user = giveProductPermissions();
$this->actingAs($user);
$product = Product::factory()->create();
$variant = ProductVariant::factory()->for($product)->create(['stock' => 100]);
$response = $this->post(route('admin.master.products.variants.transfer-stock', [$product, $variant]), [
'quantity' => 'abc',
]);
$variant->refresh();
expect($variant->stock)->toBe(100);
$this->assertDatabaseCount('stock_mutations', 0);
});
test('transfer stock quantity must be at least 1', function () {
$user = giveProductPermissions();
$this->actingAs($user);
$product = Product::factory()->create();
$variant = ProductVariant::factory()->for($product)->create(['stock' => 100]);
$response = $this->post(route('admin.master.products.variants.transfer-stock', [$product, $variant]), [
'quantity' => 0,
]);
$variant->refresh();
expect($variant->stock)->toBe(100);
$this->assertDatabaseCount('stock_mutations', 0);
});
test('transfer stock quantity cannot exceed available stock', function () {
$user = giveProductPermissions();
$this->actingAs($user);
$product = Product::factory()->create();
$variant = ProductVariant::factory()->for($product)->create(['stock' => 10, 'retail_stock' => 5]);
$response = $this->post(route('admin.master.products.variants.transfer-stock', [$product, $variant]), [
'quantity' => 15,
]);
$variant->refresh();
expect($variant->stock)->toBe(10);
expect($variant->retail_stock)->toBe(5);
$this->assertDatabaseCount('stock_mutations', 0);
});
test('transfer stock exactly equal to available stock is allowed', function () {
$user = giveProductPermissions();
$this->actingAs($user);
$product = Product::factory()->create();
$variant = ProductVariant::factory()->for($product)->create(['stock' => 10, 'retail_stock' => 5]);
$response = $this->post(route('admin.master.products.variants.transfer-stock', [$product, $variant]), [
'quantity' => 10,
]);
$response->assertRedirect();
$variant->refresh();
expect($variant->stock)->toBe(0);
expect($variant->retail_stock)->toBe(15);
});
test('transfer stock from zero stock is rejected', function () {
$user = giveProductPermissions();
$this->actingAs($user);
$product = Product::factory()->create();
$variant = ProductVariant::factory()->for($product)->create(['stock' => 0, 'retail_stock' => 0]);
$response = $this->post(route('admin.master.products.variants.transfer-stock', [$product, $variant]), [
'quantity' => 1,
]);
$variant->refresh();
expect($variant->stock)->toBe(0);
expect($variant->retail_stock)->toBe(0);
$this->assertDatabaseCount('stock_mutations', 0);
});
test('transfer stock multiple times sequentially', function () {
$user = giveProductPermissions();
$this->actingAs($user);
$product = Product::factory()->create();
$variant = ProductVariant::factory()->for($product)->create(['stock' => 100, 'retail_stock' => 0]);
$this->post(route('admin.master.products.variants.transfer-stock', [$product, $variant]), ['quantity' => 20]);
$this->post(route('admin.master.products.variants.transfer-stock', [$product, $variant]), ['quantity' => 30]);
$this->post(route('admin.master.products.variants.transfer-stock', [$product, $variant]), ['quantity' => 10]);
$variant->refresh();
expect($variant->stock)->toBe(40);
expect($variant->retail_stock)->toBe(60);
$this->assertDatabaseCount('stock_mutations', 6);
});
test('transfer stock does not affect reject_stock', function () {
$user = giveProductPermissions();
$this->actingAs($user);
$product = Product::factory()->create();
$variant = ProductVariant::factory()->for($product)->create([
'stock' => 100,
'reject_stock' => 50,
'retail_stock' => 10,
]);
$this->post(route('admin.master.products.variants.transfer-stock', [$product, $variant]), [
'quantity' => 25,
]);
$variant->refresh();
expect($variant->stock)->toBe(75);
expect($variant->reject_stock)->toBe(50);
expect($variant->retail_stock)->toBe(35);
});
test('transfer stock on non-existent variant returns 404', function () {
$user = giveProductPermissions();
$this->actingAs($user);
$product = Product::factory()->create();
$response = $this->post(route('admin.master.products.variants.transfer-stock', [$product, 99999]), [
'quantity' => 10,
]);
$response->assertStatus(404);
});
test('transfer stock redirects to product index', function () {
$user = giveProductPermissions();
$this->actingAs($user);
$product = Product::factory()->create();
$variant = ProductVariant::factory()->for($product)->create(['stock' => 100]);
$response = $this->post(route('admin.master.products.variants.transfer-stock', [$product, $variant]), [
'quantity' => 10,
]);
$response->assertRedirect(route('admin.master.products.index'));
});
/*
|--------------------------------------------------------------------------
| STOCK MUTATION AUDIT TRAIL
|--------------------------------------------------------------------------
*/
test('creating product with stock creates initial stock mutations', function () {
$user = giveProductPermissions();
$this->actingAs($user);
$this->post(route('admin.master.products.store'), makeValidProductPayload([
'variants' => [[
'name' => 'Varian Audit',
'stock' => 50,
'reject_stock' => 5,
'retail_stock' => 10,
'photo_keys' => ['product-variant/audit.jpg'],
'prices' => allPriceTypes(),
]],
]));
$variant = ProductVariant::where('name', 'Varian Audit')->first();
$this->assertDatabaseCount('stock_mutations', 3);
$goodMutation = StockMutation::where('stockable_id', $variant->id)->where('stock_quality', 'good')->first();
expect($goodMutation)->not->toBeNull();
expect($goodMutation->type)->toBe('in');
expect((float) $goodMutation->quantity)->toBe(50.0);
expect((float) $goodMutation->stock_before)->toBe(0.0);
expect((float) $goodMutation->stock_after)->toBe(50.0);
expect($goodMutation->description)->toBe('Stok awal saat pembuatan varian');
$rejectMutation = StockMutation::where('stockable_id', $variant->id)->where('stock_quality', 'reject')->first();
expect((float) $rejectMutation->quantity)->toBe(5.0);
$retailMutation = StockMutation::where('stockable_id', $variant->id)->where('stock_quality', 'retail')->first();
expect((float) $retailMutation->quantity)->toBe(10.0);
});
test('creating product with zero stock creates no mutations', function () {
$user = giveProductPermissions();
$this->actingAs($user);
$this->post(route('admin.master.products.store'), makeValidProductPayload([
'variants' => [[
'name' => 'Varian Kosong',
'stock' => 0,
'reject_stock' => 0,
'retail_stock' => 0,
'photo_keys' => ['product-variant/empty.jpg'],
'prices' => allPriceTypes(),
]],
]));
$this->assertDatabaseCount('stock_mutations', 0);
});
test('updating variant stock creates adjustment mutations', function () {
$user = giveProductPermissions();
$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()->for($product)->create([
'name' => 'Original',
'stock' => 100,
'reject_stock' => 10,
'retail_stock' => 20,
]);
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([
'name' => $product->name,
'category_ids' => [$category->id],
'variants' => [[
'id' => $variant->id,
'name' => 'Original',
'stock' => 120,
'reject_stock' => 10,
'retail_stock' => 25,
'photo_keys' => ['product-variant/updated.jpg'],
'prices' => allPriceTypes(),
]],
]));
$goodMutation = StockMutation::where('stockable_id', $variant->id)->where('stock_quality', 'good')->first();
expect($goodMutation)->not->toBeNull();
expect($goodMutation->type)->toBe('in');
expect((float) $goodMutation->quantity)->toBe(20.0);
expect((float) $goodMutation->stock_before)->toBe(100.0);
expect((float) $goodMutation->stock_after)->toBe(120.0);
$retailMutation = StockMutation::where('stockable_id', $variant->id)->where('stock_quality', 'retail')->first();
expect((float) $retailMutation->quantity)->toBe(5.0);
});
test('updating variant via single edit creates adjustment mutations', function () {
$user = giveProductPermissions();
$this->actingAs($user);
$product = Product::factory()->create();
$variant = ProductVariant::factory()->for($product)->create([
'name' => 'Single Edit',
'stock' => 80,
'reject_stock' => 5,
'retail_stock' => 15,
]);
$this->put(route('admin.master.products.variants.update', [$product, $variant]), [
'name' => 'Single Edit',
'stock' => 70,
'reject_stock' => 8,
'retail_stock' => 15,
'photo_keys' => ['product-variant/single-edit.jpg'],
'prices' => allPriceTypes(),
]);
$goodMutation = StockMutation::where('stockable_id', $variant->id)->where('stock_quality', 'good')->first();
expect($goodMutation)->not->toBeNull();
expect($goodMutation->type)->toBe('out');
expect((float) $goodMutation->quantity)->toBe(-10.0);
$rejectMutation = StockMutation::where('stockable_id', $variant->id)->where('stock_quality', 'reject')->first();
expect((float) $rejectMutation->quantity)->toBe(3.0);
$retailMutation = StockMutation::where('stockable_id', $variant->id)->where('stock_quality', 'retail')->first();
expect($retailMutation)->toBeNull();
});
test('no mutation created when stock values unchanged', function () {
$user = giveProductPermissions();
$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()->for($product)->create([
'name' => 'Unchanged',
'stock' => 50,
'reject_stock' => 10,
'retail_stock' => 20,
]);
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([
'name' => 'Updated Name Only',
'category_ids' => [$category->id],
'variants' => [[
'id' => $variant->id,
'name' => 'Unchanged',
'stock' => 50,
'reject_stock' => 10,
'retail_stock' => 20,
'photo_keys' => ['product-variant/unchanged.jpg'],
'prices' => allPriceTypes(),
]],
]));
$this->assertDatabaseCount('stock_mutations', 0);
});
test('stock mutations record user who performed the action', function () {
$user = giveProductPermissions();
$this->actingAs($user);
$product = Product::factory()->create();
$variant = ProductVariant::factory()->for($product)->create(['stock' => 100]);
$this->post(route('admin.master.products.variants.transfer-stock', [$product, $variant]), [
'quantity' => 10,
]);
$mutation = StockMutation::where('stockable_id', $variant->id)->first();
expect($mutation->user_id)->toBe($user->id);
});
/*
|--------------------------------------------------------------------------
| STOCK MUTATIONS PAGE
|--------------------------------------------------------------------------
*/
test('guest cannot access stock mutations page', function () {
$product = Product::factory()->create();
$variant = ProductVariant::factory()->for($product)->create();
$response = $this->get(route('admin.master.products.variants.stock-mutations', [$product, $variant]));
$response->assertRedirect(route('login'));
});
test('authenticated user can access stock mutations page', function () {
$user = giveProductPermissions();
$this->actingAs($user);
$product = Product::factory()->create();
$variant = ProductVariant::factory()->for($product)->create();
$response = $this->get(route('admin.master.products.variants.stock-mutations', [$product, $variant]));
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->component('admin/master/product/variant/stock-mutations')
->has('mutations.data', 0)
->where('product.id', $product->id)
->where('variant.id', $variant->id)
);
});
test('stock mutations page displays mutations for specific variant', function () {
$user = giveProductPermissions();
$this->actingAs($user);
$product = Product::factory()->create();
$variant = ProductVariant::factory()->for($product)->create(['stock' => 100]);
$this->post(route('admin.master.products.variants.transfer-stock', [$product, $variant]), [
'quantity' => 10,
]);
$response = $this->get(route('admin.master.products.variants.stock-mutations', [$product, $variant]));
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->component('admin/master/product/variant/stock-mutations')
->has('mutations.data', 2)
);
});
test('stock mutations page does not show mutations from other variants', function () {
$user = giveProductPermissions();
$this->actingAs($user);
$product = Product::factory()->create();
$variant1 = ProductVariant::factory()->for($product)->create(['stock' => 100]);
$variant2 = ProductVariant::factory()->for($product)->create(['stock' => 50]);
$this->post(route('admin.master.products.variants.transfer-stock', [$product, $variant1]), [
'quantity' => 10,
]);
$response = $this->get(route('admin.master.products.variants.stock-mutations', [$product, $variant1]));
$response->assertInertia(fn (Assert $page) => $page
->component('admin/master/product/variant/stock-mutations')
->has('mutations.data', 2)
);
$response = $this->get(route('admin.master.products.variants.stock-mutations', [$product, $variant2]));
$response->assertInertia(fn (Assert $page) => $page
->component('admin/master/product/variant/stock-mutations')
->has('mutations.data', 0)
);
});
test('stock mutations page shows correct product and variant info', function () {
$user = giveProductPermissions();
$this->actingAs($user);
$product = Product::factory()->create(['name' => 'Produk Test']);
$variant = ProductVariant::factory()->for($product)->create(['name' => 'Varian Test']);
$response = $this->get(route('admin.master.products.variants.stock-mutations', [$product, $variant]));
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->where('product.name', 'Produk Test')
->where('variant.name', 'Varian Test')
);
});