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' => '', ])); $response->assertSessionHasNoErrors(); $this->assertDatabaseHas('products', ['name' => '']); }); 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' => 'Size 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' => ' 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! 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 = 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') ); });