feat: DB 연결 오버라이딩 및 대시보드 통계 위젯 추가
- DB 연결: 로컬/Docker 환경 오버라이딩 설정 (.env) - 테넌트 위젯: redirect 버그 수정 (TenantSelectorWidget) - 통계 위젯: 사용자/제품/자재/주문 카드 추가 (StatsOverviewWidget) - 리소스 한국어화: Product, Material 모델 레이블 추가 - 대시보드: 위젯 등록 및 캐시 최적화 🤖 Generated with [Claude Code](https://claude.ai/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
538
tests/Feature/Design/BomConditionRuleTest.php
Normal file
538
tests/Feature/Design/BomConditionRuleTest.php
Normal file
@@ -0,0 +1,538 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Design;
|
||||
|
||||
use Tests\TestCase;
|
||||
use App\Models\Design\DesignModel;
|
||||
use App\Models\Design\ModelParameter;
|
||||
use App\Models\Design\ModelFormula;
|
||||
use App\Models\Design\BomConditionRule;
|
||||
use App\Models\Product;
|
||||
use App\Models\Material;
|
||||
use App\Models\User;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
|
||||
class BomConditionRuleTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private User $user;
|
||||
private Tenant $tenant;
|
||||
private DesignModel $model;
|
||||
private Product $product;
|
||||
private Material $material;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Create test tenant and user
|
||||
$this->tenant = Tenant::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
|
||||
// Associate user with tenant
|
||||
$this->user->tenants()->attach($this->tenant->id, ['is_active' => true, 'is_default' => true]);
|
||||
|
||||
// Create test design model
|
||||
$this->model = DesignModel::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'code' => 'TEST01',
|
||||
'name' => 'Test Model',
|
||||
'is_active' => true
|
||||
]);
|
||||
|
||||
// Create test product and material
|
||||
$this->product = Product::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'code' => 'PROD001',
|
||||
'name' => 'Test Product'
|
||||
]);
|
||||
|
||||
$this->material = Material::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'code' => 'MAT001',
|
||||
'name' => 'Test Material'
|
||||
]);
|
||||
|
||||
// Create test parameters
|
||||
ModelParameter::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'model_id' => $this->model->id,
|
||||
'parameter_name' => 'width',
|
||||
'parameter_type' => 'NUMBER'
|
||||
]);
|
||||
|
||||
ModelParameter::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'model_id' => $this->model->id,
|
||||
'parameter_name' => 'screen_type',
|
||||
'parameter_type' => 'SELECT',
|
||||
'options' => ['FABRIC', 'STEEL', 'PLASTIC']
|
||||
]);
|
||||
|
||||
// Authenticate user
|
||||
Sanctum::actingAs($this->user, ['*']);
|
||||
Auth::login($this->user);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function can_create_bom_condition_rule()
|
||||
{
|
||||
$ruleData = [
|
||||
'model_id' => $this->model->id,
|
||||
'rule_name' => 'Large Width Rule',
|
||||
'condition_expression' => 'width > 1000',
|
||||
'action_type' => 'INCLUDE',
|
||||
'target_type' => 'PRODUCT',
|
||||
'target_id' => $this->product->id,
|
||||
'quantity_multiplier' => 2.0,
|
||||
'description' => 'Add extra product for large widths',
|
||||
'sort_order' => 1
|
||||
];
|
||||
|
||||
$response = $this->postJson('/api/v1/design/bom-condition-rules', $ruleData);
|
||||
|
||||
$response->assertStatus(201)
|
||||
->assertJson([
|
||||
'success' => true,
|
||||
'message' => 'message.created'
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('bom_condition_rules', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'model_id' => $this->model->id,
|
||||
'rule_name' => 'Large Width Rule',
|
||||
'condition_expression' => 'width > 1000',
|
||||
'action_type' => 'INCLUDE'
|
||||
]);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function can_list_bom_condition_rules()
|
||||
{
|
||||
// Create test rules
|
||||
BomConditionRule::factory()->count(3)->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'model_id' => $this->model->id
|
||||
]);
|
||||
|
||||
$response = $this->getJson('/api/v1/design/bom-condition-rules?model_id=' . $this->model->id);
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJson([
|
||||
'success' => true,
|
||||
'message' => 'message.fetched'
|
||||
])
|
||||
->assertJsonStructure([
|
||||
'data' => [
|
||||
'data' => [
|
||||
'*' => [
|
||||
'id',
|
||||
'rule_name',
|
||||
'condition_expression',
|
||||
'action_type',
|
||||
'target_type',
|
||||
'target_id',
|
||||
'quantity_multiplier',
|
||||
'description',
|
||||
'sort_order'
|
||||
]
|
||||
]
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function can_update_bom_condition_rule()
|
||||
{
|
||||
$rule = BomConditionRule::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'model_id' => $this->model->id,
|
||||
'rule_name' => 'Original Rule',
|
||||
'condition_expression' => 'width > 500'
|
||||
]);
|
||||
|
||||
$updateData = [
|
||||
'rule_name' => 'Updated Rule',
|
||||
'condition_expression' => 'width > 800',
|
||||
'quantity_multiplier' => 1.5,
|
||||
'description' => 'Updated description'
|
||||
];
|
||||
|
||||
$response = $this->putJson('/api/v1/design/bom-condition-rules/' . $rule->id, $updateData);
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJson([
|
||||
'success' => true,
|
||||
'message' => 'message.updated'
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('bom_condition_rules', [
|
||||
'id' => $rule->id,
|
||||
'rule_name' => 'Updated Rule',
|
||||
'condition_expression' => 'width > 800',
|
||||
'quantity_multiplier' => 1.5
|
||||
]);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function can_delete_bom_condition_rule()
|
||||
{
|
||||
$rule = BomConditionRule::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'model_id' => $this->model->id
|
||||
]);
|
||||
|
||||
$response = $this->deleteJson('/api/v1/design/bom-condition-rules/' . $rule->id);
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJson([
|
||||
'success' => true,
|
||||
'message' => 'message.deleted'
|
||||
]);
|
||||
|
||||
$this->assertSoftDeleted('bom_condition_rules', [
|
||||
'id' => $rule->id
|
||||
]);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function validates_condition_expression_syntax()
|
||||
{
|
||||
// Test invalid expression
|
||||
$invalidData = [
|
||||
'model_id' => $this->model->id,
|
||||
'rule_name' => 'Invalid Rule',
|
||||
'condition_expression' => 'width >>> 1000', // Invalid syntax
|
||||
'action_type' => 'INCLUDE',
|
||||
'target_type' => 'PRODUCT',
|
||||
'target_id' => $this->product->id
|
||||
];
|
||||
|
||||
$response = $this->postJson('/api/v1/design/bom-condition-rules', $invalidData);
|
||||
$response->assertStatus(422);
|
||||
|
||||
// Test valid expression
|
||||
$validData = [
|
||||
'model_id' => $this->model->id,
|
||||
'rule_name' => 'Valid Rule',
|
||||
'condition_expression' => 'width > 1000 AND screen_type == "FABRIC"',
|
||||
'action_type' => 'INCLUDE',
|
||||
'target_type' => 'PRODUCT',
|
||||
'target_id' => $this->product->id
|
||||
];
|
||||
|
||||
$response = $this->postJson('/api/v1/design/bom-condition-rules', $validData);
|
||||
$response->assertStatus(201);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function can_evaluate_simple_conditions()
|
||||
{
|
||||
// Create simple numeric rule
|
||||
BomConditionRule::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'model_id' => $this->model->id,
|
||||
'rule_name' => 'Width Rule',
|
||||
'condition_expression' => 'width > 1000',
|
||||
'action_type' => 'INCLUDE',
|
||||
'target_type' => 'PRODUCT',
|
||||
'target_id' => $this->product->id
|
||||
]);
|
||||
|
||||
$parameters = ['width' => 1200];
|
||||
|
||||
$response = $this->postJson('/api/v1/design/bom-condition-rules/evaluate', [
|
||||
'model_id' => $this->model->id,
|
||||
'parameters' => $parameters
|
||||
]);
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJson([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'matched_rules' => [
|
||||
[
|
||||
'rule_name' => 'Width Rule',
|
||||
'condition_result' => true
|
||||
]
|
||||
]
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function can_evaluate_complex_conditions()
|
||||
{
|
||||
// Create complex rule with multiple conditions
|
||||
BomConditionRule::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'model_id' => $this->model->id,
|
||||
'rule_name' => 'Complex Rule',
|
||||
'condition_expression' => 'width > 800 AND screen_type == "FABRIC"',
|
||||
'action_type' => 'INCLUDE',
|
||||
'target_type' => 'MATERIAL',
|
||||
'target_id' => $this->material->id
|
||||
]);
|
||||
|
||||
// Test case 1: Both conditions true
|
||||
$parameters1 = ['width' => 1000, 'screen_type' => 'FABRIC'];
|
||||
$response1 = $this->postJson('/api/v1/design/bom-condition-rules/evaluate', [
|
||||
'model_id' => $this->model->id,
|
||||
'parameters' => $parameters1
|
||||
]);
|
||||
$response1->assertStatus(200);
|
||||
$this->assertTrue($response1->json('data.matched_rules.0.condition_result'));
|
||||
|
||||
// Test case 2: One condition false
|
||||
$parameters2 = ['width' => 1000, 'screen_type' => 'STEEL'];
|
||||
$response2 = $this->postJson('/api/v1/design/bom-condition-rules/evaluate', [
|
||||
'model_id' => $this->model->id,
|
||||
'parameters' => $parameters2
|
||||
]);
|
||||
$response2->assertStatus(200);
|
||||
$this->assertFalse($response2->json('data.matched_rules.0.condition_result'));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function can_handle_different_action_types()
|
||||
{
|
||||
// Create rules for each action type
|
||||
$includeRule = BomConditionRule::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'model_id' => $this->model->id,
|
||||
'rule_name' => 'Include Rule',
|
||||
'condition_expression' => 'width > 1000',
|
||||
'action_type' => 'INCLUDE',
|
||||
'target_type' => 'PRODUCT',
|
||||
'target_id' => $this->product->id,
|
||||
'quantity_multiplier' => 2.0
|
||||
]);
|
||||
|
||||
$excludeRule = BomConditionRule::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'model_id' => $this->model->id,
|
||||
'rule_name' => 'Exclude Rule',
|
||||
'condition_expression' => 'screen_type == "PLASTIC"',
|
||||
'action_type' => 'EXCLUDE',
|
||||
'target_type' => 'MATERIAL',
|
||||
'target_id' => $this->material->id
|
||||
]);
|
||||
|
||||
$modifyRule = BomConditionRule::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'model_id' => $this->model->id,
|
||||
'rule_name' => 'Modify Rule',
|
||||
'condition_expression' => 'width > 1500',
|
||||
'action_type' => 'MODIFY_QUANTITY',
|
||||
'target_type' => 'PRODUCT',
|
||||
'target_id' => $this->product->id,
|
||||
'quantity_multiplier' => 1.5
|
||||
]);
|
||||
|
||||
$parameters = ['width' => 1600, 'screen_type' => 'PLASTIC'];
|
||||
|
||||
$response = $this->postJson('/api/v1/design/bom-condition-rules/evaluate', [
|
||||
'model_id' => $this->model->id,
|
||||
'parameters' => $parameters
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
|
||||
$data = $response->json('data');
|
||||
$this->assertCount(3, $data['bom_actions']); // All three rules should match
|
||||
|
||||
// Check action types
|
||||
$actionTypes = collect($data['bom_actions'])->pluck('action_type')->toArray();
|
||||
$this->assertContains('INCLUDE', $actionTypes);
|
||||
$this->assertContains('EXCLUDE', $actionTypes);
|
||||
$this->assertContains('MODIFY_QUANTITY', $actionTypes);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function can_handle_rule_dependencies_and_order()
|
||||
{
|
||||
// Create rules with different priorities (sort_order)
|
||||
BomConditionRule::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'model_id' => $this->model->id,
|
||||
'rule_name' => 'High Priority Rule',
|
||||
'condition_expression' => 'width > 500',
|
||||
'action_type' => 'INCLUDE',
|
||||
'target_type' => 'PRODUCT',
|
||||
'target_id' => $this->product->id,
|
||||
'sort_order' => 1
|
||||
]);
|
||||
|
||||
BomConditionRule::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'model_id' => $this->model->id,
|
||||
'rule_name' => 'Low Priority Rule',
|
||||
'condition_expression' => 'width > 500',
|
||||
'action_type' => 'EXCLUDE',
|
||||
'target_type' => 'PRODUCT',
|
||||
'target_id' => $this->product->id,
|
||||
'sort_order' => 2
|
||||
]);
|
||||
|
||||
$parameters = ['width' => 1000];
|
||||
|
||||
$response = $this->postJson('/api/v1/design/bom-condition-rules/evaluate', [
|
||||
'model_id' => $this->model->id,
|
||||
'parameters' => $parameters
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
|
||||
$actions = $response->json('data.bom_actions');
|
||||
$this->assertEquals('INCLUDE', $actions[0]['action_type']); // Higher priority first
|
||||
$this->assertEquals('EXCLUDE', $actions[1]['action_type']);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function can_validate_target_references()
|
||||
{
|
||||
// Test invalid product reference
|
||||
$invalidProductData = [
|
||||
'model_id' => $this->model->id,
|
||||
'rule_name' => 'Invalid Product Rule',
|
||||
'condition_expression' => 'width > 1000',
|
||||
'action_type' => 'INCLUDE',
|
||||
'target_type' => 'PRODUCT',
|
||||
'target_id' => 99999 // Non-existent product
|
||||
];
|
||||
|
||||
$response = $this->postJson('/api/v1/design/bom-condition-rules', $invalidProductData);
|
||||
$response->assertStatus(422);
|
||||
|
||||
// Test invalid material reference
|
||||
$invalidMaterialData = [
|
||||
'model_id' => $this->model->id,
|
||||
'rule_name' => 'Invalid Material Rule',
|
||||
'condition_expression' => 'width > 1000',
|
||||
'action_type' => 'INCLUDE',
|
||||
'target_type' => 'MATERIAL',
|
||||
'target_id' => 99999 // Non-existent material
|
||||
];
|
||||
|
||||
$response = $this->postJson('/api/v1/design/bom-condition-rules', $invalidMaterialData);
|
||||
$response->assertStatus(422);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function enforces_tenant_isolation()
|
||||
{
|
||||
// Create rule for different tenant
|
||||
$otherTenant = Tenant::factory()->create();
|
||||
$otherRule = BomConditionRule::factory()->create([
|
||||
'tenant_id' => $otherTenant->id
|
||||
]);
|
||||
|
||||
// Should not be able to access other tenant's rule
|
||||
$response = $this->getJson('/api/v1/design/bom-condition-rules/' . $otherRule->id);
|
||||
$response->assertStatus(404);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function can_test_rule_against_multiple_scenarios()
|
||||
{
|
||||
BomConditionRule::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'model_id' => $this->model->id,
|
||||
'rule_name' => 'Multi Test Rule',
|
||||
'condition_expression' => 'width > 1000 OR screen_type == "STEEL"',
|
||||
'action_type' => 'INCLUDE',
|
||||
'target_type' => 'PRODUCT',
|
||||
'target_id' => $this->product->id
|
||||
]);
|
||||
|
||||
$testScenarios = [
|
||||
['width' => 1200, 'screen_type' => 'FABRIC'], // Should match (width > 1000)
|
||||
['width' => 800, 'screen_type' => 'STEEL'], // Should match (screen_type == STEEL)
|
||||
['width' => 800, 'screen_type' => 'FABRIC'], // Should not match
|
||||
['width' => 1200, 'screen_type' => 'STEEL'] // Should match (both conditions)
|
||||
];
|
||||
|
||||
$response = $this->postJson('/api/v1/design/bom-condition-rules/test-scenarios', [
|
||||
'model_id' => $this->model->id,
|
||||
'scenarios' => $testScenarios
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
|
||||
$results = $response->json('data.scenario_results');
|
||||
$this->assertTrue($results[0]['matched']);
|
||||
$this->assertTrue($results[1]['matched']);
|
||||
$this->assertFalse($results[2]['matched']);
|
||||
$this->assertTrue($results[3]['matched']);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function can_clone_rules_between_models()
|
||||
{
|
||||
// Create source rules
|
||||
$sourceRules = BomConditionRule::factory()->count(3)->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'model_id' => $this->model->id
|
||||
]);
|
||||
|
||||
// Create target model
|
||||
$targetModel = DesignModel::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'code' => 'TARGET01'
|
||||
]);
|
||||
|
||||
$response = $this->postJson('/api/v1/design/bom-condition-rules/clone', [
|
||||
'source_model_id' => $this->model->id,
|
||||
'target_model_id' => $targetModel->id
|
||||
]);
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJson([
|
||||
'success' => true,
|
||||
'message' => 'message.cloned'
|
||||
]);
|
||||
|
||||
// Verify rules were cloned
|
||||
$this->assertDatabaseCount('bom_condition_rules', 6); // 3 original + 3 cloned
|
||||
|
||||
$clonedRules = BomConditionRule::where('model_id', $targetModel->id)->get();
|
||||
$this->assertCount(3, $clonedRules);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function can_handle_expression_with_calculated_values()
|
||||
{
|
||||
// Create formula that will be used in condition
|
||||
ModelFormula::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'model_id' => $this->model->id,
|
||||
'formula_name' => 'area',
|
||||
'expression' => 'width * 600'
|
||||
]);
|
||||
|
||||
// Create rule that uses calculated value
|
||||
BomConditionRule::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'model_id' => $this->model->id,
|
||||
'rule_name' => 'Area Rule',
|
||||
'condition_expression' => 'area > 600000',
|
||||
'action_type' => 'INCLUDE',
|
||||
'target_type' => 'MATERIAL',
|
||||
'target_id' => $this->material->id
|
||||
]);
|
||||
|
||||
$parameters = ['width' => 1200]; // area = 1200 * 600 = 720000
|
||||
|
||||
$response = $this->postJson('/api/v1/design/bom-condition-rules/evaluate', [
|
||||
'model_id' => $this->model->id,
|
||||
'parameters' => $parameters
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$this->assertTrue($response->json('data.matched_rules.0.condition_result'));
|
||||
}
|
||||
}
|
||||
708
tests/Feature/Design/BomResolverTest.php
Normal file
708
tests/Feature/Design/BomResolverTest.php
Normal file
@@ -0,0 +1,708 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Design;
|
||||
|
||||
use Tests\TestCase;
|
||||
use App\Models\Design\DesignModel;
|
||||
use App\Models\Design\ModelVersion;
|
||||
use App\Models\Design\ModelParameter;
|
||||
use App\Models\Design\ModelFormula;
|
||||
use App\Models\Design\BomConditionRule;
|
||||
use App\Models\Design\BomTemplate;
|
||||
use App\Models\Design\BomTemplateItem;
|
||||
use App\Models\Product;
|
||||
use App\Models\Material;
|
||||
use App\Models\User;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
|
||||
class BomResolverTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private User $user;
|
||||
private Tenant $tenant;
|
||||
private DesignModel $model;
|
||||
private ModelVersion $modelVersion;
|
||||
private BomTemplate $bomTemplate;
|
||||
private Product $product1;
|
||||
private Product $product2;
|
||||
private Material $material1;
|
||||
private Material $material2;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Create test tenant and user
|
||||
$this->tenant = Tenant::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
|
||||
// Associate user with tenant
|
||||
$this->user->tenants()->attach($this->tenant->id, ['is_active' => true, 'is_default' => true]);
|
||||
|
||||
// Create test design model
|
||||
$this->model = DesignModel::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'code' => 'KSS01',
|
||||
'name' => 'Screen Door System',
|
||||
'is_active' => true
|
||||
]);
|
||||
|
||||
// Create model version
|
||||
$this->modelVersion = ModelVersion::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'model_id' => $this->model->id,
|
||||
'version_no' => '1.0',
|
||||
'status' => 'RELEASED'
|
||||
]);
|
||||
|
||||
// Create products and materials
|
||||
$this->product1 = Product::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'code' => 'BRACKET001',
|
||||
'name' => 'Wall Bracket',
|
||||
'unit' => 'EA'
|
||||
]);
|
||||
|
||||
$this->product2 = Product::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'code' => 'MOTOR001',
|
||||
'name' => 'DC Motor',
|
||||
'unit' => 'EA'
|
||||
]);
|
||||
|
||||
$this->material1 = Material::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'code' => 'FABRIC001',
|
||||
'name' => 'Screen Fabric',
|
||||
'unit' => 'M2'
|
||||
]);
|
||||
|
||||
$this->material2 = Material::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'code' => 'RAIL001',
|
||||
'name' => 'Guide Rail',
|
||||
'unit' => 'M'
|
||||
]);
|
||||
|
||||
// Create BOM template
|
||||
$this->bomTemplate = BomTemplate::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'model_version_id' => $this->modelVersion->id,
|
||||
'name' => 'Base BOM Template'
|
||||
]);
|
||||
|
||||
// Create BOM template items
|
||||
BomTemplateItem::factory()->create([
|
||||
'bom_template_id' => $this->bomTemplate->id,
|
||||
'ref_type' => 'PRODUCT',
|
||||
'ref_id' => $this->product1->id,
|
||||
'quantity' => 2,
|
||||
'waste_rate' => 5,
|
||||
'order' => 1
|
||||
]);
|
||||
|
||||
BomTemplateItem::factory()->create([
|
||||
'bom_template_id' => $this->bomTemplate->id,
|
||||
'ref_type' => 'MATERIAL',
|
||||
'ref_id' => $this->material1->id,
|
||||
'quantity' => 1,
|
||||
'waste_rate' => 10,
|
||||
'order' => 2
|
||||
]);
|
||||
|
||||
// Create test parameters
|
||||
ModelParameter::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'model_id' => $this->model->id,
|
||||
'parameter_name' => 'W0',
|
||||
'parameter_type' => 'NUMBER',
|
||||
'default_value' => '800',
|
||||
'min_value' => 500,
|
||||
'max_value' => 2000,
|
||||
'unit' => 'mm'
|
||||
]);
|
||||
|
||||
ModelParameter::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'model_id' => $this->model->id,
|
||||
'parameter_name' => 'H0',
|
||||
'parameter_type' => 'NUMBER',
|
||||
'default_value' => '600',
|
||||
'min_value' => 400,
|
||||
'max_value' => 1500,
|
||||
'unit' => 'mm'
|
||||
]);
|
||||
|
||||
ModelParameter::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'model_id' => $this->model->id,
|
||||
'parameter_name' => 'screen_type',
|
||||
'parameter_type' => 'SELECT',
|
||||
'options' => ['FABRIC', 'STEEL', 'PLASTIC'],
|
||||
'default_value' => 'FABRIC'
|
||||
]);
|
||||
|
||||
// Create formulas
|
||||
ModelFormula::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'model_id' => $this->model->id,
|
||||
'formula_name' => 'W1',
|
||||
'expression' => 'W0 + 100',
|
||||
'sort_order' => 1
|
||||
]);
|
||||
|
||||
ModelFormula::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'model_id' => $this->model->id,
|
||||
'formula_name' => 'H1',
|
||||
'expression' => 'H0 + 100',
|
||||
'sort_order' => 2
|
||||
]);
|
||||
|
||||
ModelFormula::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'model_id' => $this->model->id,
|
||||
'formula_name' => 'area',
|
||||
'expression' => '(W1 * H1) / 1000000',
|
||||
'sort_order' => 3
|
||||
]);
|
||||
|
||||
// Authenticate user
|
||||
Sanctum::actingAs($this->user, ['*']);
|
||||
Auth::login($this->user);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function can_resolve_basic_bom_without_rules()
|
||||
{
|
||||
$inputParameters = [
|
||||
'W0' => 1000,
|
||||
'H0' => 800
|
||||
];
|
||||
|
||||
$response = $this->postJson('/api/v1/design/bom-resolver/resolve', [
|
||||
'model_id' => $this->model->id,
|
||||
'parameters' => $inputParameters
|
||||
]);
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonStructure([
|
||||
'success',
|
||||
'data' => [
|
||||
'model' => ['id', 'code', 'name'],
|
||||
'input_parameters',
|
||||
'calculated_values',
|
||||
'matched_rules',
|
||||
'base_bom_template_id',
|
||||
'resolved_bom',
|
||||
'summary'
|
||||
]
|
||||
]);
|
||||
|
||||
$data = $response->json('data');
|
||||
|
||||
// Check calculated values
|
||||
$this->assertEquals(1100, $data['calculated_values']['W1']); // 1000 + 100
|
||||
$this->assertEquals(900, $data['calculated_values']['H1']); // 800 + 100
|
||||
$this->assertEquals(0.99, $data['calculated_values']['area']); // (1100 * 900) / 1000000
|
||||
|
||||
// Check resolved BOM contains base template items
|
||||
$this->assertCount(2, $data['resolved_bom']);
|
||||
$this->assertEquals('PRODUCT', $data['resolved_bom'][0]['target_type']);
|
||||
$this->assertEquals($this->product1->id, $data['resolved_bom'][0]['target_id']);
|
||||
$this->assertEquals(2, $data['resolved_bom'][0]['quantity']);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function can_resolve_bom_with_include_rules()
|
||||
{
|
||||
// Create rule to include motor for large widths
|
||||
BomConditionRule::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'model_id' => $this->model->id,
|
||||
'rule_name' => 'Large Width Motor',
|
||||
'condition_expression' => 'W0 > 1200',
|
||||
'action_type' => 'INCLUDE',
|
||||
'target_type' => 'PRODUCT',
|
||||
'target_id' => $this->product2->id,
|
||||
'quantity_multiplier' => 1,
|
||||
'sort_order' => 1
|
||||
]);
|
||||
|
||||
$inputParameters = [
|
||||
'W0' => 1500, // Triggers the rule
|
||||
'H0' => 800
|
||||
];
|
||||
|
||||
$response = $this->postJson('/api/v1/design/bom-resolver/resolve', [
|
||||
'model_id' => $this->model->id,
|
||||
'parameters' => $inputParameters
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$data = $response->json('data');
|
||||
|
||||
// Should have base template items + motor
|
||||
$this->assertCount(3, $data['resolved_bom']);
|
||||
|
||||
// Check if motor was added
|
||||
$motorItem = collect($data['resolved_bom'])->firstWhere('target_id', $this->product2->id);
|
||||
$this->assertNotNull($motorItem);
|
||||
$this->assertEquals('PRODUCT', $motorItem['target_type']);
|
||||
$this->assertEquals(1, $motorItem['quantity']);
|
||||
$this->assertEquals('Large Width Motor', $motorItem['reason']);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function can_resolve_bom_with_exclude_rules()
|
||||
{
|
||||
// Create rule to exclude fabric for steel type
|
||||
BomConditionRule::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'model_id' => $this->model->id,
|
||||
'rule_name' => 'Steel Type Exclude Fabric',
|
||||
'condition_expression' => 'screen_type == "STEEL"',
|
||||
'action_type' => 'EXCLUDE',
|
||||
'target_type' => 'MATERIAL',
|
||||
'target_id' => $this->material1->id,
|
||||
'sort_order' => 1
|
||||
]);
|
||||
|
||||
$inputParameters = [
|
||||
'W0' => 1000,
|
||||
'H0' => 800,
|
||||
'screen_type' => 'STEEL'
|
||||
];
|
||||
|
||||
$response = $this->postJson('/api/v1/design/bom-resolver/resolve', [
|
||||
'model_id' => $this->model->id,
|
||||
'parameters' => $inputParameters
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$data = $response->json('data');
|
||||
|
||||
// Should only have bracket (fabric excluded)
|
||||
$this->assertCount(1, $data['resolved_bom']);
|
||||
$this->assertEquals($this->product1->id, $data['resolved_bom'][0]['target_id']);
|
||||
|
||||
// Verify fabric is not in BOM
|
||||
$fabricItem = collect($data['resolved_bom'])->firstWhere('target_id', $this->material1->id);
|
||||
$this->assertNull($fabricItem);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function can_resolve_bom_with_modify_quantity_rules()
|
||||
{
|
||||
// Create rule to modify bracket quantity for large areas
|
||||
BomConditionRule::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'model_id' => $this->model->id,
|
||||
'rule_name' => 'Large Area Extra Brackets',
|
||||
'condition_expression' => 'area > 1.0',
|
||||
'action_type' => 'MODIFY_QUANTITY',
|
||||
'target_type' => 'PRODUCT',
|
||||
'target_id' => $this->product1->id,
|
||||
'quantity_multiplier' => 2.0,
|
||||
'sort_order' => 1
|
||||
]);
|
||||
|
||||
$inputParameters = [
|
||||
'W0' => 1200, // area = (1300 * 900) / 1000000 = 1.17
|
||||
'H0' => 800
|
||||
];
|
||||
|
||||
$response = $this->postJson('/api/v1/design/bom-resolver/resolve', [
|
||||
'model_id' => $this->model->id,
|
||||
'parameters' => $inputParameters
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$data = $response->json('data');
|
||||
|
||||
// Find bracket item
|
||||
$bracketItem = collect($data['resolved_bom'])->firstWhere('target_id', $this->product1->id);
|
||||
$this->assertNotNull($bracketItem);
|
||||
$this->assertEquals(4, $bracketItem['quantity']); // 2 * 2.0
|
||||
$this->assertEquals('Large Area Extra Brackets', $bracketItem['reason']);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function can_resolve_bom_with_multiple_rules()
|
||||
{
|
||||
// Create multiple rules that should all apply
|
||||
BomConditionRule::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'model_id' => $this->model->id,
|
||||
'rule_name' => 'Add Motor',
|
||||
'condition_expression' => 'W0 > 1000',
|
||||
'action_type' => 'INCLUDE',
|
||||
'target_type' => 'PRODUCT',
|
||||
'target_id' => $this->product2->id,
|
||||
'quantity_multiplier' => 1,
|
||||
'sort_order' => 1
|
||||
]);
|
||||
|
||||
BomConditionRule::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'model_id' => $this->model->id,
|
||||
'rule_name' => 'Add Rail',
|
||||
'condition_expression' => 'screen_type == "FABRIC"',
|
||||
'action_type' => 'INCLUDE',
|
||||
'target_type' => 'MATERIAL',
|
||||
'target_id' => $this->material2->id,
|
||||
'quantity_multiplier' => 2.0,
|
||||
'sort_order' => 2
|
||||
]);
|
||||
|
||||
$inputParameters = [
|
||||
'W0' => 1200, // Triggers first rule
|
||||
'H0' => 800,
|
||||
'screen_type' => 'FABRIC' // Triggers second rule
|
||||
];
|
||||
|
||||
$response = $this->postJson('/api/v1/design/bom-resolver/resolve', [
|
||||
'model_id' => $this->model->id,
|
||||
'parameters' => $inputParameters
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$data = $response->json('data');
|
||||
|
||||
// Should have all items: bracket, fabric (base) + motor, rail (rules)
|
||||
$this->assertCount(4, $data['resolved_bom']);
|
||||
|
||||
// Check all matched rules
|
||||
$this->assertCount(2, $data['matched_rules']);
|
||||
|
||||
// Verify motor was added
|
||||
$motorItem = collect($data['resolved_bom'])->firstWhere('target_id', $this->product2->id);
|
||||
$this->assertNotNull($motorItem);
|
||||
|
||||
// Verify rail was added
|
||||
$railItem = collect($data['resolved_bom'])->firstWhere('target_id', $this->material2->id);
|
||||
$this->assertNotNull($railItem);
|
||||
$this->assertEquals(2.0, $railItem['quantity']);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function can_preview_bom_without_saving()
|
||||
{
|
||||
$inputParameters = [
|
||||
'W0' => 1000,
|
||||
'H0' => 800
|
||||
];
|
||||
|
||||
$response = $this->postJson('/api/v1/design/bom-resolver/preview', [
|
||||
'model_id' => $this->model->id,
|
||||
'parameters' => $inputParameters
|
||||
]);
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonStructure([
|
||||
'success',
|
||||
'data' => [
|
||||
'model',
|
||||
'input_parameters',
|
||||
'calculated_values',
|
||||
'resolved_bom',
|
||||
'summary'
|
||||
]
|
||||
]);
|
||||
|
||||
// Verify it's the same as resolve but without persistence
|
||||
$data = $response->json('data');
|
||||
$this->assertIsArray($data['resolved_bom']);
|
||||
$this->assertIsArray($data['summary']);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function can_compare_bom_by_different_parameters()
|
||||
{
|
||||
$parameters1 = [
|
||||
'W0' => 800,
|
||||
'H0' => 600,
|
||||
'screen_type' => 'FABRIC'
|
||||
];
|
||||
|
||||
$parameters2 = [
|
||||
'W0' => 1200,
|
||||
'H0' => 800,
|
||||
'screen_type' => 'STEEL'
|
||||
];
|
||||
|
||||
$response = $this->postJson('/api/v1/design/bom-resolver/compare', [
|
||||
'model_id' => $this->model->id,
|
||||
'parameters1' => $parameters1,
|
||||
'parameters2' => $parameters2
|
||||
]);
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonStructure([
|
||||
'success',
|
||||
'data' => [
|
||||
'parameters_diff' => [
|
||||
'set1',
|
||||
'set2',
|
||||
'changed'
|
||||
],
|
||||
'calculated_values_diff' => [
|
||||
'set1',
|
||||
'set2',
|
||||
'changed'
|
||||
],
|
||||
'bom_diff' => [
|
||||
'added',
|
||||
'removed',
|
||||
'modified',
|
||||
'summary'
|
||||
],
|
||||
'summary_diff'
|
||||
]
|
||||
]);
|
||||
|
||||
$data = $response->json('data');
|
||||
|
||||
// Check parameter differences
|
||||
$this->assertEquals(400, $data['parameters_diff']['changed']['W0']); // 1200 - 800
|
||||
$this->assertEquals(200, $data['parameters_diff']['changed']['H0']); // 800 - 600
|
||||
$this->assertEquals('STEEL', $data['parameters_diff']['changed']['screen_type']);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function can_save_and_retrieve_bom_resolution()
|
||||
{
|
||||
$inputParameters = [
|
||||
'W0' => 1000,
|
||||
'H0' => 800
|
||||
];
|
||||
|
||||
// First resolve and save
|
||||
$resolveResponse = $this->postJson('/api/v1/design/bom-resolver/resolve', [
|
||||
'model_id' => $this->model->id,
|
||||
'parameters' => $inputParameters,
|
||||
'save_resolution' => true,
|
||||
'purpose' => 'ESTIMATION'
|
||||
]);
|
||||
|
||||
$resolveResponse->assertStatus(200);
|
||||
$resolutionId = $resolveResponse->json('data.resolution_id');
|
||||
$this->assertNotNull($resolutionId);
|
||||
|
||||
// Then retrieve saved resolution
|
||||
$retrieveResponse = $this->getJson('/api/v1/design/bom-resolver/resolution/' . $resolutionId);
|
||||
|
||||
$retrieveResponse->assertStatus(200)
|
||||
->assertJsonStructure([
|
||||
'success',
|
||||
'data' => [
|
||||
'resolution_id',
|
||||
'model_id',
|
||||
'input_parameters',
|
||||
'calculated_values',
|
||||
'resolved_bom',
|
||||
'purpose',
|
||||
'saved_at'
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function handles_missing_bom_template_gracefully()
|
||||
{
|
||||
// Delete the BOM template
|
||||
$this->bomTemplate->delete();
|
||||
|
||||
$inputParameters = [
|
||||
'W0' => 1000,
|
||||
'H0' => 800
|
||||
];
|
||||
|
||||
$response = $this->postJson('/api/v1/design/bom-resolver/resolve', [
|
||||
'model_id' => $this->model->id,
|
||||
'parameters' => $inputParameters
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$data = $response->json('data');
|
||||
|
||||
// Should return empty BOM with calculated values
|
||||
$this->assertEmpty($data['resolved_bom']);
|
||||
$this->assertNull($data['base_bom_template_id']);
|
||||
$this->assertNotEmpty($data['calculated_values']);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function validates_input_parameters()
|
||||
{
|
||||
// Test missing required parameter
|
||||
$invalidParameters = [
|
||||
'H0' => 800
|
||||
// Missing W0
|
||||
];
|
||||
|
||||
$response = $this->postJson('/api/v1/design/bom-resolver/resolve', [
|
||||
'model_id' => $this->model->id,
|
||||
'parameters' => $invalidParameters
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
|
||||
// Test parameter out of range
|
||||
$outOfRangeParameters = [
|
||||
'W0' => 3000, // Max is 2000
|
||||
'H0' => 800
|
||||
];
|
||||
|
||||
$response = $this->postJson('/api/v1/design/bom-resolver/resolve', [
|
||||
'model_id' => $this->model->id,
|
||||
'parameters' => $outOfRangeParameters
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function enforces_tenant_isolation()
|
||||
{
|
||||
// Create model for different tenant
|
||||
$otherTenant = Tenant::factory()->create();
|
||||
$otherModel = DesignModel::factory()->create([
|
||||
'tenant_id' => $otherTenant->id
|
||||
]);
|
||||
|
||||
$inputParameters = [
|
||||
'W0' => 1000,
|
||||
'H0' => 800
|
||||
];
|
||||
|
||||
$response = $this->postJson('/api/v1/design/bom-resolver/resolve', [
|
||||
'model_id' => $otherModel->id,
|
||||
'parameters' => $inputParameters
|
||||
]);
|
||||
|
||||
$response->assertStatus(404);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function can_handle_bom_with_waste_rates()
|
||||
{
|
||||
$inputParameters = [
|
||||
'W0' => 1000,
|
||||
'H0' => 800
|
||||
];
|
||||
|
||||
$response = $this->postJson('/api/v1/design/bom-resolver/resolve', [
|
||||
'model_id' => $this->model->id,
|
||||
'parameters' => $inputParameters
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$data = $response->json('data');
|
||||
|
||||
// Check waste rate calculation
|
||||
$bracketItem = collect($data['resolved_bom'])->firstWhere('target_id', $this->product1->id);
|
||||
$this->assertNotNull($bracketItem);
|
||||
$this->assertEquals(2, $bracketItem['quantity']); // Base quantity
|
||||
$this->assertEquals(2.1, $bracketItem['actual_quantity']); // 2 * (1 + 5/100)
|
||||
|
||||
$fabricItem = collect($data['resolved_bom'])->firstWhere('target_id', $this->material1->id);
|
||||
$this->assertNotNull($fabricItem);
|
||||
$this->assertEquals(1, $fabricItem['quantity']); // Base quantity
|
||||
$this->assertEquals(1.1, $fabricItem['actual_quantity']); // 1 * (1 + 10/100)
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function can_test_kss01_specific_scenario()
|
||||
{
|
||||
// Test KSS01 specific logic using the built-in test method
|
||||
$kssParameters = [
|
||||
'W0' => 1200,
|
||||
'H0' => 800,
|
||||
'screen_type' => 'FABRIC',
|
||||
'install_type' => 'WALL'
|
||||
];
|
||||
|
||||
$response = $this->postJson('/api/v1/design/bom-resolver/kss01-test', [
|
||||
'parameters' => $kssParameters
|
||||
]);
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonStructure([
|
||||
'success',
|
||||
'data' => [
|
||||
'model' => ['code', 'name'],
|
||||
'input_parameters',
|
||||
'calculated_values' => ['W1', 'H1', 'area'],
|
||||
'resolved_bom',
|
||||
'summary'
|
||||
]
|
||||
]);
|
||||
|
||||
$data = $response->json('data');
|
||||
$this->assertEquals('KSS01', $data['model']['code']);
|
||||
$this->assertEquals(1300, $data['calculated_values']['W1']); // 1200 + 100
|
||||
$this->assertEquals(900, $data['calculated_values']['H1']); // 800 + 100
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function can_handle_formula_calculation_errors()
|
||||
{
|
||||
// Create formula with potential division by zero
|
||||
ModelFormula::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'model_id' => $this->model->id,
|
||||
'formula_name' => 'ratio',
|
||||
'expression' => 'W0 / (H0 - 800)', // Division by zero when H0 = 800
|
||||
'sort_order' => 4
|
||||
]);
|
||||
|
||||
$inputParameters = [
|
||||
'W0' => 1000,
|
||||
'H0' => 800 // This will cause division by zero
|
||||
];
|
||||
|
||||
$response = $this->postJson('/api/v1/design/bom-resolver/resolve', [
|
||||
'model_id' => $this->model->id,
|
||||
'parameters' => $inputParameters
|
||||
]);
|
||||
|
||||
// Should handle the error gracefully
|
||||
$response->assertStatus(422)
|
||||
->assertJsonFragment([
|
||||
'error' => 'Formula calculation error'
|
||||
]);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function generates_comprehensive_summary()
|
||||
{
|
||||
$inputParameters = [
|
||||
'W0' => 1000,
|
||||
'H0' => 800
|
||||
];
|
||||
|
||||
$response = $this->postJson('/api/v1/design/bom-resolver/resolve', [
|
||||
'model_id' => $this->model->id,
|
||||
'parameters' => $inputParameters
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$data = $response->json('data');
|
||||
|
||||
$summary = $data['summary'];
|
||||
$this->assertArrayHasKey('total_items', $summary);
|
||||
$this->assertArrayHasKey('material_count', $summary);
|
||||
$this->assertArrayHasKey('product_count', $summary);
|
||||
$this->assertArrayHasKey('total_estimated_value', $summary);
|
||||
$this->assertArrayHasKey('generated_at', $summary);
|
||||
|
||||
$this->assertEquals(2, $summary['total_items']);
|
||||
$this->assertEquals(1, $summary['material_count']);
|
||||
$this->assertEquals(1, $summary['product_count']);
|
||||
}
|
||||
}
|
||||
436
tests/Feature/Design/ModelFormulaTest.php
Normal file
436
tests/Feature/Design/ModelFormulaTest.php
Normal file
@@ -0,0 +1,436 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Design;
|
||||
|
||||
use Tests\TestCase;
|
||||
use App\Models\Design\DesignModel;
|
||||
use App\Models\Design\ModelParameter;
|
||||
use App\Models\Design\ModelFormula;
|
||||
use App\Models\User;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
|
||||
class ModelFormulaTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private User $user;
|
||||
private Tenant $tenant;
|
||||
private DesignModel $model;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Create test tenant and user
|
||||
$this->tenant = Tenant::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
|
||||
// Associate user with tenant
|
||||
$this->user->tenants()->attach($this->tenant->id, ['is_active' => true, 'is_default' => true]);
|
||||
|
||||
// Create test design model
|
||||
$this->model = DesignModel::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'code' => 'TEST01',
|
||||
'name' => 'Test Model',
|
||||
'is_active' => true
|
||||
]);
|
||||
|
||||
// Create test parameters
|
||||
ModelParameter::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'model_id' => $this->model->id,
|
||||
'parameter_name' => 'W0',
|
||||
'parameter_type' => 'NUMBER'
|
||||
]);
|
||||
|
||||
ModelParameter::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'model_id' => $this->model->id,
|
||||
'parameter_name' => 'H0',
|
||||
'parameter_type' => 'NUMBER'
|
||||
]);
|
||||
|
||||
// Authenticate user
|
||||
Sanctum::actingAs($this->user, ['*']);
|
||||
Auth::login($this->user);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function can_create_model_formula()
|
||||
{
|
||||
$formulaData = [
|
||||
'model_id' => $this->model->id,
|
||||
'formula_name' => 'W1',
|
||||
'expression' => 'W0 + 100',
|
||||
'description' => 'Outer width calculation',
|
||||
'sort_order' => 1
|
||||
];
|
||||
|
||||
$response = $this->postJson('/api/v1/design/model-formulas', $formulaData);
|
||||
|
||||
$response->assertStatus(201)
|
||||
->assertJson([
|
||||
'success' => true,
|
||||
'message' => 'message.created'
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('model_formulas', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'model_id' => $this->model->id,
|
||||
'formula_name' => 'W1',
|
||||
'expression' => 'W0 + 100'
|
||||
]);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function can_list_model_formulas()
|
||||
{
|
||||
// Create test formulas
|
||||
ModelFormula::factory()->count(3)->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'model_id' => $this->model->id
|
||||
]);
|
||||
|
||||
$response = $this->getJson('/api/v1/design/model-formulas?model_id=' . $this->model->id);
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJson([
|
||||
'success' => true,
|
||||
'message' => 'message.fetched'
|
||||
])
|
||||
->assertJsonStructure([
|
||||
'data' => [
|
||||
'data' => [
|
||||
'*' => [
|
||||
'id',
|
||||
'formula_name',
|
||||
'expression',
|
||||
'description',
|
||||
'dependencies',
|
||||
'sort_order'
|
||||
]
|
||||
]
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function can_update_model_formula()
|
||||
{
|
||||
$formula = ModelFormula::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'model_id' => $this->model->id,
|
||||
'formula_name' => 'area',
|
||||
'expression' => 'W0 * H0'
|
||||
]);
|
||||
|
||||
$updateData = [
|
||||
'formula_name' => 'area_updated',
|
||||
'expression' => '(W0 * H0) / 1000000',
|
||||
'description' => 'Area in square meters'
|
||||
];
|
||||
|
||||
$response = $this->putJson('/api/v1/design/model-formulas/' . $formula->id, $updateData);
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJson([
|
||||
'success' => true,
|
||||
'message' => 'message.updated'
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('model_formulas', [
|
||||
'id' => $formula->id,
|
||||
'formula_name' => 'area_updated',
|
||||
'expression' => '(W0 * H0) / 1000000'
|
||||
]);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function can_delete_model_formula()
|
||||
{
|
||||
$formula = ModelFormula::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'model_id' => $this->model->id
|
||||
]);
|
||||
|
||||
$response = $this->deleteJson('/api/v1/design/model-formulas/' . $formula->id);
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJson([
|
||||
'success' => true,
|
||||
'message' => 'message.deleted'
|
||||
]);
|
||||
|
||||
$this->assertSoftDeleted('model_formulas', [
|
||||
'id' => $formula->id
|
||||
]);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function validates_formula_expression_syntax()
|
||||
{
|
||||
// Test invalid expression
|
||||
$invalidData = [
|
||||
'model_id' => $this->model->id,
|
||||
'formula_name' => 'invalid_formula',
|
||||
'expression' => 'W0 +++ H0' // Invalid syntax
|
||||
];
|
||||
|
||||
$response = $this->postJson('/api/v1/design/model-formulas', $invalidData);
|
||||
$response->assertStatus(422);
|
||||
|
||||
// Test valid expression
|
||||
$validData = [
|
||||
'model_id' => $this->model->id,
|
||||
'formula_name' => 'valid_formula',
|
||||
'expression' => 'sqrt(W0^2 + H0^2)'
|
||||
];
|
||||
|
||||
$response = $this->postJson('/api/v1/design/model-formulas', $validData);
|
||||
$response->assertStatus(201);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function can_calculate_formulas_with_dependencies()
|
||||
{
|
||||
// Create formulas with dependencies
|
||||
ModelFormula::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'model_id' => $this->model->id,
|
||||
'formula_name' => 'W1',
|
||||
'expression' => 'W0 + 100',
|
||||
'sort_order' => 1
|
||||
]);
|
||||
|
||||
ModelFormula::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'model_id' => $this->model->id,
|
||||
'formula_name' => 'H1',
|
||||
'expression' => 'H0 + 100',
|
||||
'sort_order' => 2
|
||||
]);
|
||||
|
||||
ModelFormula::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'model_id' => $this->model->id,
|
||||
'formula_name' => 'area',
|
||||
'expression' => 'W1 * H1',
|
||||
'sort_order' => 3
|
||||
]);
|
||||
|
||||
$inputParameters = [
|
||||
'W0' => 800,
|
||||
'H0' => 600
|
||||
];
|
||||
|
||||
$response = $this->postJson('/api/v1/design/model-formulas/calculate', [
|
||||
'model_id' => $this->model->id,
|
||||
'parameters' => $inputParameters
|
||||
]);
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJson([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'W1' => 900, // 800 + 100
|
||||
'H1' => 700, // 600 + 100
|
||||
'area' => 630000 // 900 * 700
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function can_detect_circular_dependencies()
|
||||
{
|
||||
// Create circular dependency: A depends on B, B depends on A
|
||||
ModelFormula::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'model_id' => $this->model->id,
|
||||
'formula_name' => 'A',
|
||||
'expression' => 'B + 10'
|
||||
]);
|
||||
|
||||
ModelFormula::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'model_id' => $this->model->id,
|
||||
'formula_name' => 'B',
|
||||
'expression' => 'A + 20'
|
||||
]);
|
||||
|
||||
$response = $this->postJson('/api/v1/design/model-formulas/calculate', [
|
||||
'model_id' => $this->model->id,
|
||||
'parameters' => ['W0' => 100]
|
||||
]);
|
||||
|
||||
$response->assertStatus(422) // Validation error for circular dependency
|
||||
->assertJsonFragment([
|
||||
'error' => 'Circular dependency detected'
|
||||
]);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function can_handle_complex_mathematical_expressions()
|
||||
{
|
||||
// Test various mathematical functions
|
||||
$complexFormulas = [
|
||||
['name' => 'sqrt_test', 'expression' => 'sqrt(W0^2 + H0^2)'],
|
||||
['name' => 'trig_test', 'expression' => 'sin(W0 * pi() / 180)'],
|
||||
['name' => 'conditional_test', 'expression' => 'if(W0 > 1000, W0 * 1.2, W0 * 1.1)'],
|
||||
['name' => 'round_test', 'expression' => 'round(W0 / 100) * 100'],
|
||||
['name' => 'max_test', 'expression' => 'max(W0, H0)']
|
||||
];
|
||||
|
||||
foreach ($complexFormulas as $formulaData) {
|
||||
ModelFormula::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'model_id' => $this->model->id,
|
||||
'formula_name' => $formulaData['name'],
|
||||
'expression' => $formulaData['expression']
|
||||
]);
|
||||
}
|
||||
|
||||
$response = $this->postJson('/api/v1/design/model-formulas/calculate', [
|
||||
'model_id' => $this->model->id,
|
||||
'parameters' => ['W0' => 1200, 'H0' => 800]
|
||||
]);
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonStructure([
|
||||
'success',
|
||||
'data' => [
|
||||
'sqrt_test',
|
||||
'trig_test',
|
||||
'conditional_test',
|
||||
'round_test',
|
||||
'max_test'
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function can_validate_formula_dependencies()
|
||||
{
|
||||
// Create formula that references non-existent parameter
|
||||
$invalidFormulaData = [
|
||||
'model_id' => $this->model->id,
|
||||
'formula_name' => 'invalid_ref',
|
||||
'expression' => 'NONEXISTENT_PARAM + 100'
|
||||
];
|
||||
|
||||
$response = $this->postJson('/api/v1/design/model-formulas', $invalidFormulaData);
|
||||
$response->assertStatus(422);
|
||||
|
||||
// Create valid formula that references existing parameter
|
||||
$validFormulaData = [
|
||||
'model_id' => $this->model->id,
|
||||
'formula_name' => 'valid_ref',
|
||||
'expression' => 'W0 + 100'
|
||||
];
|
||||
|
||||
$response = $this->postJson('/api/v1/design/model-formulas', $validFormulaData);
|
||||
$response->assertStatus(201);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function enforces_tenant_isolation()
|
||||
{
|
||||
// Create formula for different tenant
|
||||
$otherTenant = Tenant::factory()->create();
|
||||
$otherFormula = ModelFormula::factory()->create([
|
||||
'tenant_id' => $otherTenant->id
|
||||
]);
|
||||
|
||||
// Should not be able to access other tenant's formula
|
||||
$response = $this->getJson('/api/v1/design/model-formulas/' . $otherFormula->id);
|
||||
$response->assertStatus(404);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function can_export_and_import_formulas()
|
||||
{
|
||||
// Create test formulas
|
||||
ModelFormula::factory()->count(3)->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'model_id' => $this->model->id
|
||||
]);
|
||||
|
||||
// Export formulas
|
||||
$response = $this->getJson('/api/v1/design/model-formulas/export?model_id=' . $this->model->id);
|
||||
$response->assertStatus(200)
|
||||
->assertJsonStructure([
|
||||
'success',
|
||||
'data' => [
|
||||
'formulas' => [
|
||||
'*' => [
|
||||
'formula_name',
|
||||
'expression',
|
||||
'description',
|
||||
'sort_order'
|
||||
]
|
||||
]
|
||||
]
|
||||
]);
|
||||
|
||||
$exportData = $response->json('data.formulas');
|
||||
|
||||
// Import formulas to new model
|
||||
$newModel = DesignModel::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'code' => 'TEST02'
|
||||
]);
|
||||
|
||||
$response = $this->postJson('/api/v1/design/model-formulas/import', [
|
||||
'model_id' => $newModel->id,
|
||||
'formulas' => $exportData
|
||||
]);
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJson([
|
||||
'success' => true,
|
||||
'message' => 'message.bulk_import'
|
||||
]);
|
||||
|
||||
$this->assertDatabaseCount('model_formulas', 6); // 3 original + 3 imported
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function can_reorder_formulas_for_calculation_sequence()
|
||||
{
|
||||
$formula1 = ModelFormula::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'model_id' => $this->model->id,
|
||||
'formula_name' => 'area',
|
||||
'expression' => 'W1 * H1',
|
||||
'sort_order' => 1
|
||||
]);
|
||||
|
||||
$formula2 = ModelFormula::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'model_id' => $this->model->id,
|
||||
'formula_name' => 'W1',
|
||||
'expression' => 'W0 + 100',
|
||||
'sort_order' => 2
|
||||
]);
|
||||
|
||||
// Reorder so W1 is calculated before area
|
||||
$reorderData = [
|
||||
['id' => $formula2->id, 'sort_order' => 1],
|
||||
['id' => $formula1->id, 'sort_order' => 2]
|
||||
];
|
||||
|
||||
$response = $this->postJson('/api/v1/design/model-formulas/reorder', [
|
||||
'items' => $reorderData
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
|
||||
$this->assertDatabaseHas('model_formulas', [
|
||||
'id' => $formula2->id,
|
||||
'sort_order' => 1
|
||||
]);
|
||||
}
|
||||
}
|
||||
339
tests/Feature/Design/ModelParameterTest.php
Normal file
339
tests/Feature/Design/ModelParameterTest.php
Normal file
@@ -0,0 +1,339 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Design;
|
||||
|
||||
use Tests\TestCase;
|
||||
use App\Models\Design\DesignModel;
|
||||
use App\Models\Design\ModelParameter;
|
||||
use App\Models\User;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
|
||||
class ModelParameterTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private User $user;
|
||||
private Tenant $tenant;
|
||||
private DesignModel $model;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Create test tenant and user
|
||||
$this->tenant = Tenant::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
|
||||
// Associate user with tenant
|
||||
$this->user->tenants()->attach($this->tenant->id, ['is_active' => true, 'is_default' => true]);
|
||||
|
||||
// Create test design model
|
||||
$this->model = DesignModel::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'code' => 'TEST01',
|
||||
'name' => 'Test Model',
|
||||
'is_active' => true
|
||||
]);
|
||||
|
||||
// Authenticate user
|
||||
Sanctum::actingAs($this->user, ['*']);
|
||||
Auth::login($this->user);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function can_create_model_parameter()
|
||||
{
|
||||
$parameterData = [
|
||||
'model_id' => $this->model->id,
|
||||
'parameter_name' => 'width',
|
||||
'parameter_type' => 'NUMBER',
|
||||
'is_required' => true,
|
||||
'default_value' => '800',
|
||||
'min_value' => 100,
|
||||
'max_value' => 2000,
|
||||
'unit' => 'mm',
|
||||
'description' => 'Width parameter for model',
|
||||
'sort_order' => 1
|
||||
];
|
||||
|
||||
$response = $this->postJson('/api/v1/design/model-parameters', $parameterData);
|
||||
|
||||
$response->assertStatus(201)
|
||||
->assertJson([
|
||||
'success' => true,
|
||||
'message' => 'message.created'
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('model_parameters', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'model_id' => $this->model->id,
|
||||
'parameter_name' => 'width',
|
||||
'parameter_type' => 'NUMBER'
|
||||
]);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function can_list_model_parameters()
|
||||
{
|
||||
// Create test parameters
|
||||
ModelParameter::factory()->count(3)->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'model_id' => $this->model->id
|
||||
]);
|
||||
|
||||
$response = $this->getJson('/api/v1/design/model-parameters?model_id=' . $this->model->id);
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJson([
|
||||
'success' => true,
|
||||
'message' => 'message.fetched'
|
||||
])
|
||||
->assertJsonStructure([
|
||||
'data' => [
|
||||
'data' => [
|
||||
'*' => [
|
||||
'id',
|
||||
'parameter_name',
|
||||
'parameter_type',
|
||||
'is_required',
|
||||
'default_value',
|
||||
'min_value',
|
||||
'max_value',
|
||||
'unit',
|
||||
'description',
|
||||
'sort_order'
|
||||
]
|
||||
]
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function can_show_model_parameter()
|
||||
{
|
||||
$parameter = ModelParameter::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'model_id' => $this->model->id,
|
||||
'parameter_name' => 'height',
|
||||
'parameter_type' => 'NUMBER'
|
||||
]);
|
||||
|
||||
$response = $this->getJson('/api/v1/design/model-parameters/' . $parameter->id);
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJson([
|
||||
'success' => true,
|
||||
'message' => 'message.fetched',
|
||||
'data' => [
|
||||
'id' => $parameter->id,
|
||||
'parameter_name' => 'height',
|
||||
'parameter_type' => 'NUMBER'
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function can_update_model_parameter()
|
||||
{
|
||||
$parameter = ModelParameter::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'model_id' => $this->model->id,
|
||||
'parameter_name' => 'depth',
|
||||
'min_value' => 50
|
||||
]);
|
||||
|
||||
$updateData = [
|
||||
'parameter_name' => 'depth_updated',
|
||||
'min_value' => 100,
|
||||
'max_value' => 500,
|
||||
'description' => 'Updated depth parameter'
|
||||
];
|
||||
|
||||
$response = $this->putJson('/api/v1/design/model-parameters/' . $parameter->id, $updateData);
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJson([
|
||||
'success' => true,
|
||||
'message' => 'message.updated'
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('model_parameters', [
|
||||
'id' => $parameter->id,
|
||||
'parameter_name' => 'depth_updated',
|
||||
'min_value' => 100,
|
||||
'max_value' => 500
|
||||
]);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function can_delete_model_parameter()
|
||||
{
|
||||
$parameter = ModelParameter::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'model_id' => $this->model->id
|
||||
]);
|
||||
|
||||
$response = $this->deleteJson('/api/v1/design/model-parameters/' . $parameter->id);
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJson([
|
||||
'success' => true,
|
||||
'message' => 'message.deleted'
|
||||
]);
|
||||
|
||||
$this->assertSoftDeleted('model_parameters', [
|
||||
'id' => $parameter->id
|
||||
]);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function validates_parameter_type_and_constraints()
|
||||
{
|
||||
// Test NUMBER type with invalid range
|
||||
$invalidData = [
|
||||
'model_id' => $this->model->id,
|
||||
'parameter_name' => 'invalid_width',
|
||||
'parameter_type' => 'NUMBER',
|
||||
'min_value' => 1000,
|
||||
'max_value' => 500 // max < min
|
||||
];
|
||||
|
||||
$response = $this->postJson('/api/v1/design/model-parameters', $invalidData);
|
||||
$response->assertStatus(422);
|
||||
|
||||
// Test SELECT type with options
|
||||
$validSelectData = [
|
||||
'model_id' => $this->model->id,
|
||||
'parameter_name' => 'screen_type',
|
||||
'parameter_type' => 'SELECT',
|
||||
'options' => ['FABRIC', 'STEEL', 'PLASTIC'],
|
||||
'default_value' => 'FABRIC'
|
||||
];
|
||||
|
||||
$response = $this->postJson('/api/v1/design/model-parameters', $validSelectData);
|
||||
$response->assertStatus(201);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function can_validate_parameter_values()
|
||||
{
|
||||
// Create NUMBER parameter
|
||||
$numberParam = ModelParameter::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'model_id' => $this->model->id,
|
||||
'parameter_name' => 'test_number',
|
||||
'parameter_type' => 'NUMBER',
|
||||
'min_value' => 100,
|
||||
'max_value' => 1000
|
||||
]);
|
||||
|
||||
// Test valid value
|
||||
$this->assertTrue($numberParam->validateValue(500));
|
||||
|
||||
// Test invalid values
|
||||
$this->assertFalse($numberParam->validateValue(50)); // below min
|
||||
$this->assertFalse($numberParam->validateValue(1500)); // above max
|
||||
$this->assertFalse($numberParam->validateValue('abc')); // non-numeric
|
||||
|
||||
// Create SELECT parameter
|
||||
$selectParam = ModelParameter::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'model_id' => $this->model->id,
|
||||
'parameter_name' => 'test_select',
|
||||
'parameter_type' => 'SELECT',
|
||||
'options' => ['OPTION1', 'OPTION2', 'OPTION3']
|
||||
]);
|
||||
|
||||
// Test valid and invalid options
|
||||
$this->assertTrue($selectParam->validateValue('OPTION1'));
|
||||
$this->assertFalse($selectParam->validateValue('INVALID'));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function can_cast_parameter_values()
|
||||
{
|
||||
$numberParam = ModelParameter::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'model_id' => $this->model->id,
|
||||
'parameter_type' => 'NUMBER'
|
||||
]);
|
||||
|
||||
$booleanParam = ModelParameter::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'model_id' => $this->model->id,
|
||||
'parameter_type' => 'BOOLEAN'
|
||||
]);
|
||||
|
||||
$textParam = ModelParameter::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'model_id' => $this->model->id,
|
||||
'parameter_type' => 'TEXT'
|
||||
]);
|
||||
|
||||
// Test casting
|
||||
$this->assertSame(123.5, $numberParam->castValue('123.5'));
|
||||
$this->assertSame(true, $booleanParam->castValue('1'));
|
||||
$this->assertSame('test', $textParam->castValue('test'));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function enforces_tenant_isolation()
|
||||
{
|
||||
// Create parameter for different tenant
|
||||
$otherTenant = Tenant::factory()->create();
|
||||
$otherParameter = ModelParameter::factory()->create([
|
||||
'tenant_id' => $otherTenant->id
|
||||
]);
|
||||
|
||||
// Should not be able to access other tenant's parameter
|
||||
$response = $this->getJson('/api/v1/design/model-parameters/' . $otherParameter->id);
|
||||
$response->assertStatus(404);
|
||||
|
||||
// Should not be able to list other tenant's parameters
|
||||
$response = $this->getJson('/api/v1/design/model-parameters');
|
||||
$response->assertStatus(200);
|
||||
|
||||
$data = $response->json('data.data');
|
||||
$this->assertEmpty(collect($data)->where('tenant_id', $otherTenant->id));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function can_reorder_parameters()
|
||||
{
|
||||
$param1 = ModelParameter::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'model_id' => $this->model->id,
|
||||
'sort_order' => 1
|
||||
]);
|
||||
|
||||
$param2 = ModelParameter::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'model_id' => $this->model->id,
|
||||
'sort_order' => 2
|
||||
]);
|
||||
|
||||
$reorderData = [
|
||||
['id' => $param1->id, 'sort_order' => 2],
|
||||
['id' => $param2->id, 'sort_order' => 1]
|
||||
];
|
||||
|
||||
$response = $this->postJson('/api/v1/design/model-parameters/reorder', [
|
||||
'items' => $reorderData
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
|
||||
$this->assertDatabaseHas('model_parameters', [
|
||||
'id' => $param1->id,
|
||||
'sort_order' => 2
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('model_parameters', [
|
||||
'id' => $param2->id,
|
||||
'sort_order' => 1
|
||||
]);
|
||||
}
|
||||
}
|
||||
679
tests/Feature/Design/ProductFromModelTest.php
Normal file
679
tests/Feature/Design/ProductFromModelTest.php
Normal file
@@ -0,0 +1,679 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Design;
|
||||
|
||||
use Tests\TestCase;
|
||||
use App\Models\Design\DesignModel;
|
||||
use App\Models\Design\ModelParameter;
|
||||
use App\Models\Design\ModelFormula;
|
||||
use App\Models\Design\BomConditionRule;
|
||||
use App\Models\Product;
|
||||
use App\Models\Material;
|
||||
use App\Models\Category;
|
||||
use App\Models\User;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
|
||||
class ProductFromModelTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private User $user;
|
||||
private Tenant $tenant;
|
||||
private DesignModel $model;
|
||||
private Category $category;
|
||||
private Product $baseMaterial;
|
||||
private Product $baseProduct;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Create test tenant and user
|
||||
$this->tenant = Tenant::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
|
||||
// Associate user with tenant
|
||||
$this->user->tenants()->attach($this->tenant->id, ['is_active' => true, 'is_default' => true]);
|
||||
|
||||
// Create test category
|
||||
$this->category = Category::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'name' => 'Screen Systems',
|
||||
'code' => 'SCREENS'
|
||||
]);
|
||||
|
||||
// Create test design model
|
||||
$this->model = DesignModel::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'code' => 'KSS01',
|
||||
'name' => 'Screen Door System',
|
||||
'category_id' => $this->category->id,
|
||||
'is_active' => true
|
||||
]);
|
||||
|
||||
// Create base materials and products for BOM
|
||||
$this->baseMaterial = Material::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'code' => 'FABRIC001',
|
||||
'name' => 'Screen Fabric'
|
||||
]);
|
||||
|
||||
$this->baseProduct = Product::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'code' => 'BRACKET001',
|
||||
'name' => 'Wall Bracket'
|
||||
]);
|
||||
|
||||
// Create test parameters
|
||||
ModelParameter::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'model_id' => $this->model->id,
|
||||
'parameter_name' => 'width',
|
||||
'parameter_type' => 'NUMBER',
|
||||
'unit' => 'mm'
|
||||
]);
|
||||
|
||||
ModelParameter::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'model_id' => $this->model->id,
|
||||
'parameter_name' => 'height',
|
||||
'parameter_type' => 'NUMBER',
|
||||
'unit' => 'mm'
|
||||
]);
|
||||
|
||||
ModelParameter::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'model_id' => $this->model->id,
|
||||
'parameter_name' => 'screen_type',
|
||||
'parameter_type' => 'SELECT',
|
||||
'options' => ['FABRIC', 'STEEL']
|
||||
]);
|
||||
|
||||
// Create test formulas
|
||||
ModelFormula::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'model_id' => $this->model->id,
|
||||
'formula_name' => 'outer_width',
|
||||
'expression' => 'width + 100'
|
||||
]);
|
||||
|
||||
ModelFormula::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'model_id' => $this->model->id,
|
||||
'formula_name' => 'outer_height',
|
||||
'expression' => 'height + 100'
|
||||
]);
|
||||
|
||||
// Authenticate user
|
||||
Sanctum::actingAs($this->user, ['*']);
|
||||
Auth::login($this->user);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function can_create_product_from_model_with_parameters()
|
||||
{
|
||||
$productData = [
|
||||
'model_id' => $this->model->id,
|
||||
'parameters' => [
|
||||
'width' => 1200,
|
||||
'height' => 800,
|
||||
'screen_type' => 'FABRIC'
|
||||
],
|
||||
'product_data' => [
|
||||
'code' => 'KSS01-1200x800-FABRIC',
|
||||
'name' => 'Screen Door 1200x800 Fabric',
|
||||
'category_id' => $this->category->id,
|
||||
'description' => 'Custom screen door based on KSS01 model',
|
||||
'unit' => 'EA'
|
||||
],
|
||||
'generate_bom' => true
|
||||
];
|
||||
|
||||
$response = $this->postJson('/api/v1/design/product-from-model', $productData);
|
||||
|
||||
$response->assertStatus(201)
|
||||
->assertJson([
|
||||
'success' => true,
|
||||
'message' => 'message.created'
|
||||
])
|
||||
->assertJsonStructure([
|
||||
'data' => [
|
||||
'product' => [
|
||||
'id',
|
||||
'code',
|
||||
'name',
|
||||
'category_id',
|
||||
'description'
|
||||
],
|
||||
'model_reference' => [
|
||||
'model_id',
|
||||
'input_parameters',
|
||||
'calculated_values'
|
||||
],
|
||||
'bom_created',
|
||||
'bom_items_count'
|
||||
]
|
||||
]);
|
||||
|
||||
// Verify product was created
|
||||
$this->assertDatabaseHas('products', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'code' => 'KSS01-1200x800-FABRIC',
|
||||
'name' => 'Screen Door 1200x800 Fabric'
|
||||
]);
|
||||
|
||||
$data = $response->json('data');
|
||||
$this->assertEquals($this->model->id, $data['model_reference']['model_id']);
|
||||
$this->assertEquals(1200, $data['model_reference']['input_parameters']['width']);
|
||||
$this->assertEquals(1300, $data['model_reference']['calculated_values']['outer_width']); // width + 100
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function can_create_product_with_auto_generated_code()
|
||||
{
|
||||
$productData = [
|
||||
'model_id' => $this->model->id,
|
||||
'parameters' => [
|
||||
'width' => 1000,
|
||||
'height' => 600,
|
||||
'screen_type' => 'STEEL'
|
||||
],
|
||||
'product_data' => [
|
||||
'name' => 'Custom Steel Screen',
|
||||
'category_id' => $this->category->id,
|
||||
'auto_generate_code' => true
|
||||
]
|
||||
];
|
||||
|
||||
$response = $this->postJson('/api/v1/design/product-from-model', $productData);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$data = $response->json('data');
|
||||
|
||||
// Code should be auto-generated based on model and parameters
|
||||
$expectedCode = 'KSS01-1000x600-STEEL';
|
||||
$this->assertEquals($expectedCode, $data['product']['code']);
|
||||
|
||||
$this->assertDatabaseHas('products', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'code' => $expectedCode
|
||||
]);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function can_create_product_with_bom_generation()
|
||||
{
|
||||
// Create rule for BOM generation
|
||||
BomConditionRule::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'model_id' => $this->model->id,
|
||||
'rule_name' => 'Add Fabric Material',
|
||||
'condition_expression' => 'screen_type == "FABRIC"',
|
||||
'action_type' => 'INCLUDE',
|
||||
'target_type' => 'MATERIAL',
|
||||
'target_id' => $this->baseMaterial->id,
|
||||
'quantity_multiplier' => 1.2
|
||||
]);
|
||||
|
||||
$productData = [
|
||||
'model_id' => $this->model->id,
|
||||
'parameters' => [
|
||||
'width' => 1200,
|
||||
'height' => 800,
|
||||
'screen_type' => 'FABRIC'
|
||||
],
|
||||
'product_data' => [
|
||||
'code' => 'TEST-PRODUCT-001',
|
||||
'name' => 'Test Product with BOM',
|
||||
'category_id' => $this->category->id
|
||||
],
|
||||
'generate_bom' => true
|
||||
];
|
||||
|
||||
$response = $this->postJson('/api/v1/design/product-from-model', $productData);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$data = $response->json('data');
|
||||
|
||||
$this->assertTrue($data['bom_created']);
|
||||
$this->assertGreaterThan(0, $data['bom_items_count']);
|
||||
|
||||
// Verify BOM was created in product_components table
|
||||
$product = Product::where('code', 'TEST-PRODUCT-001')->first();
|
||||
$this->assertDatabaseHas('product_components', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'product_id' => $product->id,
|
||||
'ref_type' => 'MATERIAL',
|
||||
'ref_id' => $this->baseMaterial->id
|
||||
]);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function can_create_product_without_bom_generation()
|
||||
{
|
||||
$productData = [
|
||||
'model_id' => $this->model->id,
|
||||
'parameters' => [
|
||||
'width' => 1000,
|
||||
'height' => 600,
|
||||
'screen_type' => 'STEEL'
|
||||
],
|
||||
'product_data' => [
|
||||
'code' => 'TEST-NO-BOM',
|
||||
'name' => 'Test Product without BOM',
|
||||
'category_id' => $this->category->id
|
||||
],
|
||||
'generate_bom' => false
|
||||
];
|
||||
|
||||
$response = $this->postJson('/api/v1/design/product-from-model', $productData);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$data = $response->json('data');
|
||||
|
||||
$this->assertFalse($data['bom_created']);
|
||||
$this->assertEquals(0, $data['bom_items_count']);
|
||||
|
||||
// Verify no BOM components were created
|
||||
$product = Product::where('code', 'TEST-NO-BOM')->first();
|
||||
$this->assertDatabaseMissing('product_components', [
|
||||
'product_id' => $product->id
|
||||
]);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function can_preview_product_before_creation()
|
||||
{
|
||||
$previewData = [
|
||||
'model_id' => $this->model->id,
|
||||
'parameters' => [
|
||||
'width' => 1500,
|
||||
'height' => 1000,
|
||||
'screen_type' => 'FABRIC'
|
||||
],
|
||||
'product_data' => [
|
||||
'name' => 'Preview Product',
|
||||
'category_id' => $this->category->id,
|
||||
'auto_generate_code' => true
|
||||
],
|
||||
'generate_bom' => true
|
||||
];
|
||||
|
||||
$response = $this->postJson('/api/v1/design/product-from-model/preview', $previewData);
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonStructure([
|
||||
'success',
|
||||
'data' => [
|
||||
'preview_product' => [
|
||||
'code',
|
||||
'name',
|
||||
'category_id'
|
||||
],
|
||||
'model_reference' => [
|
||||
'model_id',
|
||||
'input_parameters',
|
||||
'calculated_values'
|
||||
],
|
||||
'preview_bom' => [
|
||||
'items',
|
||||
'summary'
|
||||
]
|
||||
]
|
||||
]);
|
||||
|
||||
// Verify no actual product was created
|
||||
$data = $response->json('data');
|
||||
$this->assertDatabaseMissing('products', [
|
||||
'code' => $data['preview_product']['code']
|
||||
]);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function validates_required_parameters()
|
||||
{
|
||||
// Missing required width parameter
|
||||
$invalidData = [
|
||||
'model_id' => $this->model->id,
|
||||
'parameters' => [
|
||||
'height' => 800, // Missing width
|
||||
'screen_type' => 'FABRIC'
|
||||
],
|
||||
'product_data' => [
|
||||
'code' => 'INVALID-PRODUCT',
|
||||
'name' => 'Invalid Product'
|
||||
]
|
||||
];
|
||||
|
||||
$response = $this->postJson('/api/v1/design/product-from-model', $invalidData);
|
||||
$response->assertStatus(422);
|
||||
|
||||
// Parameter out of valid range
|
||||
$outOfRangeData = [
|
||||
'model_id' => $this->model->id,
|
||||
'parameters' => [
|
||||
'width' => -100, // Invalid negative value
|
||||
'height' => 800,
|
||||
'screen_type' => 'FABRIC'
|
||||
],
|
||||
'product_data' => [
|
||||
'code' => 'OUT-OF-RANGE',
|
||||
'name' => 'Out of Range Product'
|
||||
]
|
||||
];
|
||||
|
||||
$response = $this->postJson('/api/v1/design/product-from-model', $outOfRangeData);
|
||||
$response->assertStatus(422);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function validates_product_code_uniqueness()
|
||||
{
|
||||
// Create first product
|
||||
$productData1 = [
|
||||
'model_id' => $this->model->id,
|
||||
'parameters' => [
|
||||
'width' => 1000,
|
||||
'height' => 600,
|
||||
'screen_type' => 'FABRIC'
|
||||
],
|
||||
'product_data' => [
|
||||
'code' => 'DUPLICATE-CODE',
|
||||
'name' => 'First Product',
|
||||
'category_id' => $this->category->id
|
||||
]
|
||||
];
|
||||
|
||||
$response1 = $this->postJson('/api/v1/design/product-from-model', $productData1);
|
||||
$response1->assertStatus(201);
|
||||
|
||||
// Try to create second product with same code
|
||||
$productData2 = [
|
||||
'model_id' => $this->model->id,
|
||||
'parameters' => [
|
||||
'width' => 1200,
|
||||
'height' => 800,
|
||||
'screen_type' => 'STEEL'
|
||||
],
|
||||
'product_data' => [
|
||||
'code' => 'DUPLICATE-CODE', // Same code
|
||||
'name' => 'Second Product',
|
||||
'category_id' => $this->category->id
|
||||
]
|
||||
];
|
||||
|
||||
$response2 = $this->postJson('/api/v1/design/product-from-model', $productData2);
|
||||
$response2->assertStatus(422);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function can_create_product_with_custom_attributes()
|
||||
{
|
||||
$productData = [
|
||||
'model_id' => $this->model->id,
|
||||
'parameters' => [
|
||||
'width' => 1200,
|
||||
'height' => 800,
|
||||
'screen_type' => 'FABRIC'
|
||||
],
|
||||
'product_data' => [
|
||||
'code' => 'CUSTOM-ATTRS',
|
||||
'name' => 'Product with Custom Attributes',
|
||||
'category_id' => $this->category->id,
|
||||
'description' => 'Product with extended attributes',
|
||||
'unit' => 'SET',
|
||||
'weight' => 15.5,
|
||||
'color' => 'WHITE',
|
||||
'material_grade' => 'A-GRADE'
|
||||
],
|
||||
'custom_attributes' => [
|
||||
'installation_difficulty' => 'MEDIUM',
|
||||
'warranty_period' => '2_YEARS',
|
||||
'fire_rating' => 'B1'
|
||||
]
|
||||
];
|
||||
|
||||
$response = $this->postJson('/api/v1/design/product-from-model', $productData);
|
||||
|
||||
$response->assertStatus(201);
|
||||
|
||||
$this->assertDatabaseHas('products', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'code' => 'CUSTOM-ATTRS',
|
||||
'weight' => 15.5,
|
||||
'color' => 'WHITE'
|
||||
]);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function can_create_multiple_products_from_same_model()
|
||||
{
|
||||
$baseData = [
|
||||
'model_id' => $this->model->id,
|
||||
'generate_bom' => false
|
||||
];
|
||||
|
||||
$products = [
|
||||
[
|
||||
'parameters' => ['width' => 800, 'height' => 600, 'screen_type' => 'FABRIC'],
|
||||
'code' => 'KSS01-800x600-FABRIC'
|
||||
],
|
||||
[
|
||||
'parameters' => ['width' => 1000, 'height' => 800, 'screen_type' => 'STEEL'],
|
||||
'code' => 'KSS01-1000x800-STEEL'
|
||||
],
|
||||
[
|
||||
'parameters' => ['width' => 1200, 'height' => 1000, 'screen_type' => 'FABRIC'],
|
||||
'code' => 'KSS01-1200x1000-FABRIC'
|
||||
]
|
||||
];
|
||||
|
||||
foreach ($products as $index => $productSpec) {
|
||||
$productData = array_merge($baseData, [
|
||||
'parameters' => $productSpec['parameters'],
|
||||
'product_data' => [
|
||||
'code' => $productSpec['code'],
|
||||
'name' => 'Product ' . ($index + 1),
|
||||
'category_id' => $this->category->id
|
||||
]
|
||||
]);
|
||||
|
||||
$response = $this->postJson('/api/v1/design/product-from-model', $productData);
|
||||
$response->assertStatus(201);
|
||||
}
|
||||
|
||||
// Verify all products were created
|
||||
foreach ($products as $productSpec) {
|
||||
$this->assertDatabaseHas('products', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'code' => $productSpec['code']
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function can_list_products_created_from_model()
|
||||
{
|
||||
// Create some products from the model
|
||||
$this->createTestProductFromModel('PROD-1', ['width' => 800, 'height' => 600]);
|
||||
$this->createTestProductFromModel('PROD-2', ['width' => 1000, 'height' => 800]);
|
||||
|
||||
// Create a product not from model
|
||||
Product::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'code' => 'NON-MODEL-PROD'
|
||||
]);
|
||||
|
||||
$response = $this->getJson('/api/v1/design/product-from-model/list?model_id=' . $this->model->id);
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonStructure([
|
||||
'success',
|
||||
'data' => [
|
||||
'data' => [
|
||||
'*' => [
|
||||
'id',
|
||||
'code',
|
||||
'name',
|
||||
'model_reference' => [
|
||||
'model_id',
|
||||
'input_parameters',
|
||||
'calculated_values'
|
||||
],
|
||||
'created_at'
|
||||
]
|
||||
]
|
||||
]
|
||||
]);
|
||||
|
||||
$data = $response->json('data.data');
|
||||
$this->assertCount(2, $data); // Only products created from model
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function enforces_tenant_isolation()
|
||||
{
|
||||
// Create model for different tenant
|
||||
$otherTenant = Tenant::factory()->create();
|
||||
$otherModel = DesignModel::factory()->create([
|
||||
'tenant_id' => $otherTenant->id
|
||||
]);
|
||||
|
||||
$productData = [
|
||||
'model_id' => $otherModel->id,
|
||||
'parameters' => [
|
||||
'width' => 1000,
|
||||
'height' => 800
|
||||
],
|
||||
'product_data' => [
|
||||
'code' => 'TENANT-TEST',
|
||||
'name' => 'Tenant Test Product'
|
||||
]
|
||||
];
|
||||
|
||||
$response = $this->postJson('/api/v1/design/product-from-model', $productData);
|
||||
$response->assertStatus(404);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function can_update_existing_product_from_model()
|
||||
{
|
||||
// First create a product
|
||||
$createData = [
|
||||
'model_id' => $this->model->id,
|
||||
'parameters' => [
|
||||
'width' => 1000,
|
||||
'height' => 600,
|
||||
'screen_type' => 'FABRIC'
|
||||
],
|
||||
'product_data' => [
|
||||
'code' => 'UPDATE-TEST',
|
||||
'name' => 'Original Product',
|
||||
'category_id' => $this->category->id
|
||||
]
|
||||
];
|
||||
|
||||
$createResponse = $this->postJson('/api/v1/design/product-from-model', $createData);
|
||||
$createResponse->assertStatus(201);
|
||||
$productId = $createResponse->json('data.product.id');
|
||||
|
||||
// Then update it with new parameters
|
||||
$updateData = [
|
||||
'parameters' => [
|
||||
'width' => 1200, // Changed
|
||||
'height' => 800, // Changed
|
||||
'screen_type' => 'STEEL' // Changed
|
||||
],
|
||||
'product_data' => [
|
||||
'name' => 'Updated Product', // Changed
|
||||
'description' => 'Updated description'
|
||||
],
|
||||
'regenerate_bom' => true
|
||||
];
|
||||
|
||||
$updateResponse = $this->putJson('/api/v1/design/product-from-model/' . $productId, $updateData);
|
||||
|
||||
$updateResponse->assertStatus(200)
|
||||
->assertJson([
|
||||
'success' => true,
|
||||
'message' => 'message.updated'
|
||||
]);
|
||||
|
||||
// Verify product was updated
|
||||
$this->assertDatabaseHas('products', [
|
||||
'id' => $productId,
|
||||
'name' => 'Updated Product'
|
||||
]);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function can_clone_product_with_modified_parameters()
|
||||
{
|
||||
// Create original product
|
||||
$originalProductId = $this->createTestProductFromModel('ORIGINAL', [
|
||||
'width' => 1000,
|
||||
'height' => 600,
|
||||
'screen_type' => 'FABRIC'
|
||||
]);
|
||||
|
||||
// Clone with modified parameters
|
||||
$cloneData = [
|
||||
'source_product_id' => $originalProductId,
|
||||
'parameters' => [
|
||||
'width' => 1200, // Modified
|
||||
'height' => 600, // Same
|
||||
'screen_type' => 'STEEL' // Modified
|
||||
],
|
||||
'product_data' => [
|
||||
'code' => 'CLONED-PRODUCT',
|
||||
'name' => 'Cloned Product',
|
||||
'category_id' => $this->category->id
|
||||
]
|
||||
];
|
||||
|
||||
$response = $this->postJson('/api/v1/design/product-from-model/clone', $cloneData);
|
||||
|
||||
$response->assertStatus(201)
|
||||
->assertJson([
|
||||
'success' => true,
|
||||
'message' => 'message.cloned'
|
||||
]);
|
||||
|
||||
// Verify clone was created
|
||||
$this->assertDatabaseHas('products', [
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'code' => 'CLONED-PRODUCT'
|
||||
]);
|
||||
|
||||
// Verify original product still exists
|
||||
$this->assertDatabaseHas('products', [
|
||||
'id' => $originalProductId
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to create a test product from model
|
||||
*/
|
||||
private function createTestProductFromModel(string $code, array $parameters): int
|
||||
{
|
||||
$productData = [
|
||||
'model_id' => $this->model->id,
|
||||
'parameters' => array_merge([
|
||||
'width' => 1000,
|
||||
'height' => 600,
|
||||
'screen_type' => 'FABRIC'
|
||||
], $parameters),
|
||||
'product_data' => [
|
||||
'code' => $code,
|
||||
'name' => 'Test Product ' . $code,
|
||||
'category_id' => $this->category->id
|
||||
]
|
||||
];
|
||||
|
||||
$response = $this->postJson('/api/v1/design/product-from-model', $productData);
|
||||
return $response->json('data.product.id');
|
||||
}
|
||||
}
|
||||
606
tests/Feature/ParameterBasedBomApiTest.php
Normal file
606
tests/Feature/ParameterBasedBomApiTest.php
Normal file
@@ -0,0 +1,606 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\BomConditionRule;
|
||||
use App\Models\Model;
|
||||
use App\Models\ModelFormula;
|
||||
use App\Models\ModelParameter;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\ParameterBasedBomTestSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ParameterBasedBomApiTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private User $user;
|
||||
private Model $kss01Model;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Set up test environment
|
||||
$this->artisan('migrate');
|
||||
$this->seed(ParameterBasedBomTestSeeder::class);
|
||||
|
||||
// Create test user and authenticate
|
||||
$this->user = User::factory()->create(['tenant_id' => 1]);
|
||||
Sanctum::actingAs($this->user);
|
||||
|
||||
// Set required headers
|
||||
$this->withHeaders([
|
||||
'X-API-KEY' => config('app.api_key', 'test-api-key'),
|
||||
'Accept' => 'application/json',
|
||||
'Content-Type' => 'application/json',
|
||||
]);
|
||||
|
||||
$this->kss01Model = Model::where('code', 'KSS01')->first();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_can_get_model_parameters()
|
||||
{
|
||||
// Act
|
||||
$response = $this->getJson("/api/v1/design/models/{$this->kss01Model->id}/parameters");
|
||||
|
||||
// Assert
|
||||
$response->assertOk()
|
||||
->assertJsonStructure([
|
||||
'success',
|
||||
'message',
|
||||
'data' => [
|
||||
'*' => [
|
||||
'id',
|
||||
'name',
|
||||
'label',
|
||||
'type',
|
||||
'default_value',
|
||||
'validation_rules',
|
||||
'options',
|
||||
'sort_order',
|
||||
'is_required',
|
||||
'is_active',
|
||||
]
|
||||
]
|
||||
]);
|
||||
|
||||
$parameters = $response->json('data');
|
||||
$this->assertCount(5, $parameters); // W0, H0, screen_type, install_type, power_source
|
||||
|
||||
// Check parameter order
|
||||
$this->assertEquals('W0', $parameters[0]['name']);
|
||||
$this->assertEquals('H0', $parameters[1]['name']);
|
||||
$this->assertEquals('screen_type', $parameters[2]['name']);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_can_get_model_formulas()
|
||||
{
|
||||
// Act
|
||||
$response = $this->getJson("/api/v1/design/models/{$this->kss01Model->id}/formulas");
|
||||
|
||||
// Assert
|
||||
$response->assertOk()
|
||||
->assertJsonStructure([
|
||||
'success',
|
||||
'message',
|
||||
'data' => [
|
||||
'*' => [
|
||||
'id',
|
||||
'name',
|
||||
'expression',
|
||||
'description',
|
||||
'return_type',
|
||||
'sort_order',
|
||||
'is_active',
|
||||
]
|
||||
]
|
||||
]);
|
||||
|
||||
$formulas = $response->json('data');
|
||||
$this->assertGreaterThanOrEqual(7, count($formulas)); // W1, H1, area, weight, motor, bracket, guide
|
||||
|
||||
// Check formula order
|
||||
$this->assertEquals('W1', $formulas[0]['name']);
|
||||
$this->assertEquals('H1', $formulas[1]['name']);
|
||||
$this->assertEquals('area', $formulas[2]['name']);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_can_get_model_condition_rules()
|
||||
{
|
||||
// Act
|
||||
$response = $this->getJson("/api/v1/design/models/{$this->kss01Model->id}/rules");
|
||||
|
||||
// Assert
|
||||
$response->assertOk()
|
||||
->assertJsonStructure([
|
||||
'success',
|
||||
'message',
|
||||
'data' => [
|
||||
'*' => [
|
||||
'id',
|
||||
'name',
|
||||
'description',
|
||||
'condition_expression',
|
||||
'component_code',
|
||||
'quantity_expression',
|
||||
'priority',
|
||||
'is_active',
|
||||
]
|
||||
]
|
||||
]);
|
||||
|
||||
$rules = $response->json('data');
|
||||
$this->assertGreaterThanOrEqual(7, count($rules)); // Various case, bottom, shaft, pipe rules
|
||||
|
||||
// Check rules are ordered by priority
|
||||
$priorities = collect($rules)->pluck('priority');
|
||||
$this->assertEquals($priorities->sort()->values(), $priorities->values());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_can_resolve_bom_for_small_screen()
|
||||
{
|
||||
// Arrange
|
||||
$inputParams = [
|
||||
'W0' => 1000,
|
||||
'H0' => 800,
|
||||
'screen_type' => 'SCREEN',
|
||||
'install_type' => 'WALL',
|
||||
'power_source' => 'AC',
|
||||
];
|
||||
|
||||
// Act
|
||||
$response = $this->postJson("/api/v1/design/models/{$this->kss01Model->id}/bom/resolve", [
|
||||
'parameters' => $inputParams
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertOk()
|
||||
->assertJsonStructure([
|
||||
'success',
|
||||
'message',
|
||||
'data' => [
|
||||
'model_id',
|
||||
'input_parameters',
|
||||
'calculated_formulas' => [
|
||||
'W1',
|
||||
'H1',
|
||||
'area',
|
||||
'weight',
|
||||
'motor',
|
||||
'bracket',
|
||||
'guide',
|
||||
],
|
||||
'bom_items' => [
|
||||
'*' => [
|
||||
'component_code',
|
||||
'quantity',
|
||||
'rule_name',
|
||||
'condition_expression',
|
||||
]
|
||||
],
|
||||
'summary' => [
|
||||
'total_components',
|
||||
'total_weight',
|
||||
'component_categories',
|
||||
]
|
||||
]
|
||||
]);
|
||||
|
||||
$data = $response->json('data');
|
||||
|
||||
// Check calculated formulas
|
||||
$formulas = $data['calculated_formulas'];
|
||||
$this->assertEquals(1120, $formulas['W1']); // 1000 + 120
|
||||
$this->assertEquals(900, $formulas['H1']); // 800 + 100
|
||||
$this->assertEquals(1.008, $formulas['area']); // 1120 * 900 / 1000000
|
||||
$this->assertEquals('0.5HP', $formulas['motor']); // area <= 3
|
||||
|
||||
// Check BOM items
|
||||
$bomItems = $data['bom_items'];
|
||||
$this->assertGreaterThan(0, count($bomItems));
|
||||
|
||||
// Check specific components
|
||||
$caseItem = collect($bomItems)->first(fn($item) => str_contains($item['component_code'], 'CASE'));
|
||||
$this->assertEquals('CASE-SMALL', $caseItem['component_code']);
|
||||
|
||||
$screenPipe = collect($bomItems)->first(fn($item) => $item['component_code'] === 'PIPE-SCREEN');
|
||||
$this->assertNotNull($screenPipe);
|
||||
|
||||
$slatPipe = collect($bomItems)->first(fn($item) => $item['component_code'] === 'PIPE-SLAT');
|
||||
$this->assertNull($slatPipe);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_can_resolve_bom_for_large_screen()
|
||||
{
|
||||
// Arrange
|
||||
$inputParams = [
|
||||
'W0' => 2500,
|
||||
'H0' => 1500,
|
||||
'screen_type' => 'SCREEN',
|
||||
'install_type' => 'SIDE',
|
||||
'power_source' => 'AC',
|
||||
];
|
||||
|
||||
// Act
|
||||
$response = $this->postJson("/api/v1/design/models/{$this->kss01Model->id}/bom/resolve", [
|
||||
'parameters' => $inputParams
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertOk();
|
||||
|
||||
$data = $response->json('data');
|
||||
$formulas = $data['calculated_formulas'];
|
||||
|
||||
// Check large screen calculations
|
||||
$this->assertEquals(2620, $formulas['W1']); // 2500 + 120
|
||||
$this->assertEquals(1600, $formulas['H1']); // 1500 + 100
|
||||
$this->assertEquals(4.192, $formulas['area']); // 2620 * 1600 / 1000000
|
||||
$this->assertEquals('1HP', $formulas['motor']); // 3 < area <= 6
|
||||
|
||||
// Check medium case is selected
|
||||
$bomItems = $data['bom_items'];
|
||||
$caseItem = collect($bomItems)->first(fn($item) => str_contains($item['component_code'], 'CASE'));
|
||||
$this->assertEquals('CASE-MEDIUM', $caseItem['component_code']);
|
||||
|
||||
// Check bracket quantity
|
||||
$bottomItem = collect($bomItems)->first(fn($item) => $item['component_code'] === 'BOTTOM-001');
|
||||
$this->assertEquals(3, $bottomItem['quantity']); // CEIL(2620 / 1000)
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_can_resolve_bom_for_slat_type()
|
||||
{
|
||||
// Arrange
|
||||
$inputParams = [
|
||||
'W0' => 1500,
|
||||
'H0' => 1000,
|
||||
'screen_type' => 'SLAT',
|
||||
'install_type' => 'WALL',
|
||||
'power_source' => 'AC',
|
||||
];
|
||||
|
||||
// Act
|
||||
$response = $this->postJson("/api/v1/design/models/{$this->kss01Model->id}/bom/resolve", [
|
||||
'parameters' => $inputParams
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertOk();
|
||||
|
||||
$bomItems = $response->json('data.bom_items');
|
||||
|
||||
// Check that SLAT pipe is used
|
||||
$screenPipe = collect($bomItems)->first(fn($item) => $item['component_code'] === 'PIPE-SCREEN');
|
||||
$slatPipe = collect($bomItems)->first(fn($item) => $item['component_code'] === 'PIPE-SLAT');
|
||||
|
||||
$this->assertNull($screenPipe);
|
||||
$this->assertNotNull($slatPipe);
|
||||
$this->assertEquals('PIPE-SLAT', $slatPipe['component_code']);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_validates_missing_required_parameters()
|
||||
{
|
||||
// Arrange - Missing H0 parameter
|
||||
$incompleteParams = [
|
||||
'W0' => 1000,
|
||||
'screen_type' => 'SCREEN',
|
||||
'install_type' => 'WALL',
|
||||
'power_source' => 'AC',
|
||||
];
|
||||
|
||||
// Act
|
||||
$response = $this->postJson("/api/v1/design/models/{$this->kss01Model->id}/bom/resolve", [
|
||||
'parameters' => $incompleteParams
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors(['parameters.H0']);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_validates_parameter_ranges()
|
||||
{
|
||||
// Arrange - W0 below minimum
|
||||
$invalidParams = [
|
||||
'W0' => 100, // Below minimum (500)
|
||||
'H0' => 800,
|
||||
'screen_type' => 'SCREEN',
|
||||
'install_type' => 'WALL',
|
||||
'power_source' => 'AC',
|
||||
];
|
||||
|
||||
// Act
|
||||
$response = $this->postJson("/api/v1/design/models/{$this->kss01Model->id}/bom/resolve", [
|
||||
'parameters' => $invalidParams
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors(['parameters.W0']);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_validates_select_parameter_options()
|
||||
{
|
||||
// Arrange - Invalid screen_type
|
||||
$invalidParams = [
|
||||
'W0' => 1000,
|
||||
'H0' => 800,
|
||||
'screen_type' => 'INVALID_TYPE',
|
||||
'install_type' => 'WALL',
|
||||
'power_source' => 'AC',
|
||||
];
|
||||
|
||||
// Act
|
||||
$response = $this->postJson("/api/v1/design/models/{$this->kss01Model->id}/bom/resolve", [
|
||||
'parameters' => $invalidParams
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors(['parameters.screen_type']);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_can_create_product_from_model()
|
||||
{
|
||||
// Arrange
|
||||
$inputParams = [
|
||||
'W0' => 1200,
|
||||
'H0' => 900,
|
||||
'screen_type' => 'SCREEN',
|
||||
'install_type' => 'WALL',
|
||||
'power_source' => 'AC',
|
||||
];
|
||||
|
||||
$productData = [
|
||||
'code' => 'KSS01-TEST-001',
|
||||
'name' => '테스트 스크린 블라인드 1200x900',
|
||||
'description' => 'API 테스트로 생성된 제품',
|
||||
];
|
||||
|
||||
// Act
|
||||
$response = $this->postJson("/api/v1/design/models/{$this->kss01Model->id}/products", [
|
||||
'parameters' => $inputParams,
|
||||
'product' => $productData,
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertCreated()
|
||||
->assertJsonStructure([
|
||||
'success',
|
||||
'message',
|
||||
'data' => [
|
||||
'product' => [
|
||||
'id',
|
||||
'code',
|
||||
'name',
|
||||
'description',
|
||||
'product_type',
|
||||
'model_id',
|
||||
'parameter_values',
|
||||
],
|
||||
'bom_items' => [
|
||||
'*' => [
|
||||
'id',
|
||||
'product_id',
|
||||
'component_code',
|
||||
'component_type',
|
||||
'quantity',
|
||||
'unit',
|
||||
]
|
||||
],
|
||||
'summary' => [
|
||||
'total_components',
|
||||
'calculated_formulas',
|
||||
]
|
||||
]
|
||||
]);
|
||||
|
||||
$data = $response->json('data');
|
||||
$product = $data['product'];
|
||||
|
||||
// Check product data
|
||||
$this->assertEquals('KSS01-TEST-001', $product['code']);
|
||||
$this->assertEquals('테스트 스크린 블라인드 1200x900', $product['name']);
|
||||
$this->assertEquals($this->kss01Model->id, $product['model_id']);
|
||||
|
||||
// Check parameter values are stored
|
||||
$parameterValues = json_decode($product['parameter_values'], true);
|
||||
$this->assertEquals(1200, $parameterValues['W0']);
|
||||
$this->assertEquals(900, $parameterValues['H0']);
|
||||
|
||||
// Check BOM items
|
||||
$bomItems = $data['bom_items'];
|
||||
$this->assertGreaterThan(0, count($bomItems));
|
||||
|
||||
foreach ($bomItems as $item) {
|
||||
$this->assertEquals($product['id'], $item['product_id']);
|
||||
$this->assertNotEmpty($item['component_code']);
|
||||
$this->assertGreaterThan(0, $item['quantity']);
|
||||
}
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_can_preview_product_without_creating()
|
||||
{
|
||||
// Arrange
|
||||
$inputParams = [
|
||||
'W0' => 1800,
|
||||
'H0' => 1200,
|
||||
'screen_type' => 'SCREEN',
|
||||
'install_type' => 'SIDE',
|
||||
'power_source' => 'DC',
|
||||
];
|
||||
|
||||
// Act
|
||||
$response = $this->postJson("/api/v1/design/models/{$this->kss01Model->id}/preview", [
|
||||
'parameters' => $inputParams
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertOk()
|
||||
->assertJsonStructure([
|
||||
'success',
|
||||
'message',
|
||||
'data' => [
|
||||
'suggested_product' => [
|
||||
'code',
|
||||
'name',
|
||||
'description',
|
||||
],
|
||||
'calculated_values' => [
|
||||
'W1',
|
||||
'H1',
|
||||
'area',
|
||||
'weight',
|
||||
'motor',
|
||||
],
|
||||
'bom_preview' => [
|
||||
'total_components',
|
||||
'components' => [
|
||||
'*' => [
|
||||
'component_code',
|
||||
'quantity',
|
||||
'description',
|
||||
]
|
||||
]
|
||||
],
|
||||
'cost_estimate' => [
|
||||
'total_material_cost',
|
||||
'estimated_labor_cost',
|
||||
'total_estimated_cost',
|
||||
]
|
||||
]
|
||||
]);
|
||||
|
||||
$data = $response->json('data');
|
||||
|
||||
// Check suggested product info
|
||||
$suggestedProduct = $data['suggested_product'];
|
||||
$this->assertStringContains('KSS01', $suggestedProduct['code']);
|
||||
$this->assertStringContains('1800x1200', $suggestedProduct['name']);
|
||||
|
||||
// Check calculated values
|
||||
$calculatedValues = $data['calculated_values'];
|
||||
$this->assertEquals(1920, $calculatedValues['W1']); // 1800 + 120
|
||||
$this->assertEquals(1300, $calculatedValues['H1']); // 1200 + 100
|
||||
|
||||
// Check BOM preview
|
||||
$bomPreview = $data['bom_preview'];
|
||||
$this->assertGreaterThan(0, $bomPreview['total_components']);
|
||||
$this->assertNotEmpty($bomPreview['components']);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_handles_authentication_properly()
|
||||
{
|
||||
// Arrange - Remove authentication
|
||||
Sanctum::actingAs(null);
|
||||
|
||||
// Act
|
||||
$response = $this->getJson("/api/v1/design/models/{$this->kss01Model->id}/parameters");
|
||||
|
||||
// Assert
|
||||
$response->assertUnauthorized();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_handles_tenant_isolation()
|
||||
{
|
||||
// Arrange - Create user with different tenant
|
||||
$otherTenantUser = User::factory()->create(['tenant_id' => 2]);
|
||||
Sanctum::actingAs($otherTenantUser);
|
||||
|
||||
// Act
|
||||
$response = $this->getJson("/api/v1/design/models/{$this->kss01Model->id}/parameters");
|
||||
|
||||
// Assert
|
||||
$response->assertNotFound();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_handles_performance_for_large_datasets()
|
||||
{
|
||||
// Arrange - Use performance test model with many parameters/formulas/rules
|
||||
$performanceModel = Model::where('code', 'PERF-TEST')->first();
|
||||
|
||||
if (!$performanceModel) {
|
||||
$this->markTestSkipped('Performance test model not found. Run in testing environment.');
|
||||
}
|
||||
|
||||
$inputParams = array_fill_keys(
|
||||
ModelParameter::where('model_id', $performanceModel->id)->pluck('name')->toArray(),
|
||||
100
|
||||
);
|
||||
|
||||
// Act
|
||||
$startTime = microtime(true);
|
||||
$response = $this->postJson("/api/v1/design/models/{$performanceModel->id}/bom/resolve", [
|
||||
'parameters' => $inputParams
|
||||
]);
|
||||
$executionTime = microtime(true) - $startTime;
|
||||
|
||||
// Assert
|
||||
$response->assertOk();
|
||||
$this->assertLessThan(2.0, $executionTime, 'BOM resolution should complete within 2 seconds');
|
||||
|
||||
$data = $response->json('data');
|
||||
$this->assertArrayHasKey('calculated_formulas', $data);
|
||||
$this->assertArrayHasKey('bom_items', $data);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_returns_appropriate_error_for_nonexistent_model()
|
||||
{
|
||||
// Act
|
||||
$response = $this->getJson('/api/v1/design/models/999999/parameters');
|
||||
|
||||
// Assert
|
||||
$response->assertNotFound()
|
||||
->assertJson([
|
||||
'success' => false,
|
||||
'message' => 'error.model_not_found',
|
||||
]);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_handles_concurrent_requests_safely()
|
||||
{
|
||||
// Arrange
|
||||
$inputParams = [
|
||||
'W0' => 1000,
|
||||
'H0' => 800,
|
||||
'screen_type' => 'SCREEN',
|
||||
'install_type' => 'WALL',
|
||||
'power_source' => 'AC',
|
||||
];
|
||||
|
||||
// Act - Simulate concurrent requests
|
||||
$promises = [];
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
$promises[] = $this->postJson("/api/v1/design/models/{$this->kss01Model->id}/bom/resolve", [
|
||||
'parameters' => $inputParams
|
||||
]);
|
||||
}
|
||||
|
||||
// Assert - All should succeed with same results
|
||||
foreach ($promises as $response) {
|
||||
$response->assertOk();
|
||||
$formulas = $response->json('data.calculated_formulas');
|
||||
$this->assertEquals(1120, $formulas['W1']);
|
||||
$this->assertEquals(900, $formulas['H1']);
|
||||
}
|
||||
}
|
||||
}
|
||||
490
tests/Performance/BomResolutionPerformanceTest.php
Normal file
490
tests/Performance/BomResolutionPerformanceTest.php
Normal file
@@ -0,0 +1,490 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Performance;
|
||||
|
||||
use App\Models\BomConditionRule;
|
||||
use App\Models\Model;
|
||||
use App\Models\ModelFormula;
|
||||
use App\Models\ModelParameter;
|
||||
use App\Models\User;
|
||||
use App\Services\ProductFromModelService;
|
||||
use Database\Seeders\ParameterBasedBomTestSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
use Tests\TestCase;
|
||||
|
||||
class BomResolutionPerformanceTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private User $user;
|
||||
private Model $performanceModel;
|
||||
private ProductFromModelService $service;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->user = User::factory()->create(['tenant_id' => 1]);
|
||||
Sanctum::actingAs($this->user);
|
||||
|
||||
$this->service = new ProductFromModelService();
|
||||
$this->service->setTenantId(1)->setApiUserId(1);
|
||||
|
||||
// Set headers
|
||||
$this->withHeaders([
|
||||
'X-API-KEY' => config('app.api_key', 'test-api-key'),
|
||||
'Accept' => 'application/json',
|
||||
]);
|
||||
|
||||
// Seed test data
|
||||
$this->seed(ParameterBasedBomTestSeeder::class);
|
||||
|
||||
// Create performance test model with large dataset
|
||||
$this->createPerformanceTestModel();
|
||||
}
|
||||
|
||||
private function createPerformanceTestModel(): void
|
||||
{
|
||||
$this->performanceModel = Model::factory()->create([
|
||||
'code' => 'PERF-TEST',
|
||||
'name' => 'Performance Test Model',
|
||||
'product_family' => 'SCREEN',
|
||||
'tenant_id' => 1,
|
||||
]);
|
||||
|
||||
// Create many parameters (50)
|
||||
for ($i = 1; $i <= 50; $i++) {
|
||||
ModelParameter::factory()->create([
|
||||
'model_id' => $this->performanceModel->id,
|
||||
'name' => "param_{$i}",
|
||||
'label' => "Parameter {$i}",
|
||||
'type' => 'NUMBER',
|
||||
'default_value' => '100',
|
||||
'sort_order' => $i,
|
||||
'tenant_id' => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
// Create many formulas (30)
|
||||
for ($i = 1; $i <= 30; $i++) {
|
||||
ModelFormula::factory()->create([
|
||||
'model_id' => $this->performanceModel->id,
|
||||
'name' => "formula_{$i}",
|
||||
'expression' => "param_1 + param_2 + {$i}",
|
||||
'description' => "Formula {$i}",
|
||||
'return_type' => 'NUMBER',
|
||||
'sort_order' => $i,
|
||||
'tenant_id' => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
// Create many condition rules (100)
|
||||
for ($i = 1; $i <= 100; $i++) {
|
||||
BomConditionRule::factory()->create([
|
||||
'model_id' => $this->performanceModel->id,
|
||||
'name' => "rule_{$i}",
|
||||
'condition_expression' => "formula_1 > {$i}",
|
||||
'component_code' => "COMPONENT_{$i}",
|
||||
'quantity_expression' => '1',
|
||||
'priority' => $i,
|
||||
'tenant_id' => 1,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_resolves_simple_bom_within_performance_threshold()
|
||||
{
|
||||
// Use KSS01 model for simple test
|
||||
$kss01 = Model::where('code', 'KSS01')->first();
|
||||
|
||||
$inputParams = [
|
||||
'W0' => 1000,
|
||||
'H0' => 800,
|
||||
'screen_type' => 'SCREEN',
|
||||
'install_type' => 'WALL',
|
||||
'power_source' => 'AC',
|
||||
];
|
||||
|
||||
$startTime = microtime(true);
|
||||
|
||||
$response = $this->postJson("/api/v1/design/models/{$kss01->id}/bom/resolve", [
|
||||
'parameters' => $inputParams
|
||||
]);
|
||||
|
||||
$executionTime = microtime(true) - $startTime;
|
||||
|
||||
// Should complete within 500ms for simple model
|
||||
$this->assertLessThan(0.5, $executionTime, 'Simple BOM resolution took too long');
|
||||
$response->assertOk();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_handles_complex_bom_resolution_efficiently()
|
||||
{
|
||||
// Create parameters for all 50 parameters
|
||||
$inputParams = [];
|
||||
for ($i = 1; $i <= 50; $i++) {
|
||||
$inputParams["param_{$i}"] = 100 + $i;
|
||||
}
|
||||
|
||||
$startTime = microtime(true);
|
||||
|
||||
$response = $this->postJson("/api/v1/design/models/{$this->performanceModel->id}/bom/resolve", [
|
||||
'parameters' => $inputParams
|
||||
]);
|
||||
|
||||
$executionTime = microtime(true) - $startTime;
|
||||
|
||||
// Should complete within 2 seconds even for complex model
|
||||
$this->assertLessThan(2.0, $executionTime, 'Complex BOM resolution took too long');
|
||||
$response->assertOk();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_handles_concurrent_bom_resolutions()
|
||||
{
|
||||
$kss01 = Model::where('code', 'KSS01')->first();
|
||||
|
||||
$inputParams = [
|
||||
'W0' => 1000,
|
||||
'H0' => 800,
|
||||
'screen_type' => 'SCREEN',
|
||||
'install_type' => 'WALL',
|
||||
'power_source' => 'AC',
|
||||
];
|
||||
|
||||
$startTime = microtime(true);
|
||||
$responses = [];
|
||||
|
||||
// Simulate 10 concurrent requests
|
||||
for ($i = 0; $i < 10; $i++) {
|
||||
$responses[] = $this->postJson("/api/v1/design/models/{$kss01->id}/bom/resolve", [
|
||||
'parameters' => $inputParams
|
||||
]);
|
||||
}
|
||||
|
||||
$totalTime = microtime(true) - $startTime;
|
||||
|
||||
// All requests should complete successfully
|
||||
foreach ($responses as $response) {
|
||||
$response->assertOk();
|
||||
}
|
||||
|
||||
// Total time for 10 concurrent requests should be reasonable
|
||||
$this->assertLessThan(5.0, $totalTime, 'Concurrent BOM resolutions took too long');
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_optimizes_formula_evaluation_with_caching()
|
||||
{
|
||||
$kss01 = Model::where('code', 'KSS01')->first();
|
||||
|
||||
$inputParams = [
|
||||
'W0' => 1000,
|
||||
'H0' => 800,
|
||||
'screen_type' => 'SCREEN',
|
||||
'install_type' => 'WALL',
|
||||
'power_source' => 'AC',
|
||||
];
|
||||
|
||||
// First request (cold cache)
|
||||
$startTime1 = microtime(true);
|
||||
$response1 = $this->postJson("/api/v1/design/models/{$kss01->id}/bom/resolve", [
|
||||
'parameters' => $inputParams
|
||||
]);
|
||||
$time1 = microtime(true) - $startTime1;
|
||||
|
||||
// Second request with same parameters (warm cache)
|
||||
$startTime2 = microtime(true);
|
||||
$response2 = $this->postJson("/api/v1/design/models/{$kss01->id}/bom/resolve", [
|
||||
'parameters' => $inputParams
|
||||
]);
|
||||
$time2 = microtime(true) - $startTime2;
|
||||
|
||||
// Both should succeed
|
||||
$response1->assertOk();
|
||||
$response2->assertOk();
|
||||
|
||||
// Results should be identical
|
||||
$this->assertEquals($response1->json('data'), $response2->json('data'));
|
||||
|
||||
// Second request should be faster (with caching)
|
||||
$this->assertLessThan($time1, $time2 * 1.5, 'Caching is not improving performance');
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_measures_memory_usage_during_bom_resolution()
|
||||
{
|
||||
$kss01 = Model::where('code', 'KSS01')->first();
|
||||
|
||||
$inputParams = [
|
||||
'W0' => 1000,
|
||||
'H0' => 800,
|
||||
'screen_type' => 'SCREEN',
|
||||
'install_type' => 'WALL',
|
||||
'power_source' => 'AC',
|
||||
];
|
||||
|
||||
$memoryBefore = memory_get_usage(true);
|
||||
|
||||
$response = $this->postJson("/api/v1/design/models/{$kss01->id}/bom/resolve", [
|
||||
'parameters' => $inputParams
|
||||
]);
|
||||
|
||||
$memoryAfter = memory_get_usage(true);
|
||||
$memoryUsed = $memoryAfter - $memoryBefore;
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
// Memory usage should be reasonable (less than 50MB for simple BOM)
|
||||
$this->assertLessThan(50 * 1024 * 1024, $memoryUsed, 'Excessive memory usage detected');
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_handles_large_datasets_efficiently()
|
||||
{
|
||||
// Test with the performance model (50 params, 30 formulas, 100 rules)
|
||||
$inputParams = [];
|
||||
for ($i = 1; $i <= 50; $i++) {
|
||||
$inputParams["param_{$i}"] = rand(50, 200);
|
||||
}
|
||||
|
||||
$memoryBefore = memory_get_usage(true);
|
||||
$startTime = microtime(true);
|
||||
|
||||
$response = $this->postJson("/api/v1/design/models/{$this->performanceModel->id}/bom/resolve", [
|
||||
'parameters' => $inputParams
|
||||
]);
|
||||
|
||||
$executionTime = microtime(true) - $startTime;
|
||||
$memoryAfter = memory_get_usage(true);
|
||||
$memoryUsed = $memoryAfter - $memoryBefore;
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
// Performance thresholds for large datasets
|
||||
$this->assertLessThan(5.0, $executionTime, 'Large dataset processing took too long');
|
||||
$this->assertLessThan(100 * 1024 * 1024, $memoryUsed, 'Excessive memory usage for large dataset');
|
||||
|
||||
// Should return reasonable amount of data
|
||||
$data = $response->json('data');
|
||||
$this->assertArrayHasKey('calculated_formulas', $data);
|
||||
$this->assertArrayHasKey('bom_items', $data);
|
||||
$this->assertGreaterThan(0, count($data['bom_items']));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_benchmarks_formula_evaluation_complexity()
|
||||
{
|
||||
// Test various formula complexities
|
||||
$complexityTests = [
|
||||
'simple' => 'param_1 + param_2',
|
||||
'medium' => 'param_1 * param_2 + param_3 / param_4',
|
||||
'complex' => 'CEIL(param_1 / 600) + FLOOR(param_2 * 1.5) + IF(param_3 > 100, param_4, param_5)',
|
||||
];
|
||||
|
||||
$benchmarks = [];
|
||||
|
||||
foreach ($complexityTests as $complexity => $expression) {
|
||||
// Create test formula
|
||||
$formula = ModelFormula::factory()->create([
|
||||
'model_id' => $this->performanceModel->id,
|
||||
'name' => "benchmark_{$complexity}",
|
||||
'expression' => $expression,
|
||||
'sort_order' => 999,
|
||||
'tenant_id' => 1,
|
||||
]);
|
||||
|
||||
$inputParams = [
|
||||
'param_1' => 1000,
|
||||
'param_2' => 800,
|
||||
'param_3' => 150,
|
||||
'param_4' => 200,
|
||||
'param_5' => 50,
|
||||
];
|
||||
|
||||
$startTime = microtime(true);
|
||||
|
||||
// Evaluate formula multiple times to get average
|
||||
for ($i = 0; $i < 100; $i++) {
|
||||
try {
|
||||
$this->service->evaluateFormula($formula, $inputParams);
|
||||
} catch (\Exception $e) {
|
||||
// Some complex formulas might fail, that's okay for benchmarking
|
||||
}
|
||||
}
|
||||
|
||||
$avgTime = (microtime(true) - $startTime) / 100;
|
||||
$benchmarks[$complexity] = $avgTime;
|
||||
|
||||
// Cleanup
|
||||
$formula->delete();
|
||||
}
|
||||
|
||||
// Complex formulas should still execute reasonably fast
|
||||
$this->assertLessThan(0.01, $benchmarks['simple'], 'Simple formula evaluation too slow');
|
||||
$this->assertLessThan(0.02, $benchmarks['medium'], 'Medium formula evaluation too slow');
|
||||
$this->assertLessThan(0.05, $benchmarks['complex'], 'Complex formula evaluation too slow');
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_scales_with_increasing_rule_count()
|
||||
{
|
||||
// Test BOM resolution with different rule counts
|
||||
$scalingTests = [10, 50, 100];
|
||||
$scalingResults = [];
|
||||
|
||||
foreach ($scalingTests as $ruleCount) {
|
||||
// Create test model with specific rule count
|
||||
$testModel = Model::factory()->create([
|
||||
'code' => "SCALE_TEST_{$ruleCount}",
|
||||
'tenant_id' => 1,
|
||||
]);
|
||||
|
||||
// Create basic parameters
|
||||
ModelParameter::factory()->create([
|
||||
'model_id' => $testModel->id,
|
||||
'name' => 'test_param',
|
||||
'type' => 'NUMBER',
|
||||
'tenant_id' => 1,
|
||||
]);
|
||||
|
||||
// Create test formula
|
||||
ModelFormula::factory()->create([
|
||||
'model_id' => $testModel->id,
|
||||
'name' => 'test_formula',
|
||||
'expression' => 'test_param * 2',
|
||||
'tenant_id' => 1,
|
||||
]);
|
||||
|
||||
// Create specified number of rules
|
||||
for ($i = 1; $i <= $ruleCount; $i++) {
|
||||
BomConditionRule::factory()->create([
|
||||
'model_id' => $testModel->id,
|
||||
'condition_expression' => "test_formula > {$i}",
|
||||
'component_code' => "COMP_{$i}",
|
||||
'priority' => $i,
|
||||
'tenant_id' => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
$startTime = microtime(true);
|
||||
|
||||
$response = $this->postJson("/api/v1/design/models/{$testModel->id}/bom/resolve", [
|
||||
'parameters' => ['test_param' => 100]
|
||||
]);
|
||||
|
||||
$executionTime = microtime(true) - $startTime;
|
||||
$scalingResults[$ruleCount] = $executionTime;
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
// Cleanup
|
||||
$testModel->delete();
|
||||
}
|
||||
|
||||
// Execution time should scale reasonably (not exponentially)
|
||||
$ratio50to10 = $scalingResults[50] / $scalingResults[10];
|
||||
$ratio100to50 = $scalingResults[100] / $scalingResults[50];
|
||||
|
||||
// Should not scale worse than linearly
|
||||
$this->assertLessThan(10, $ratio50to10, 'Poor scaling from 10 to 50 rules');
|
||||
$this->assertLessThan(5, $ratio100to50, 'Poor scaling from 50 to 100 rules');
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_handles_stress_test_scenarios()
|
||||
{
|
||||
$kss01 = Model::where('code', 'KSS01')->first();
|
||||
|
||||
// Stress test with many rapid requests
|
||||
$stressTestCount = 50;
|
||||
$successCount = 0;
|
||||
$errors = [];
|
||||
$totalTime = 0;
|
||||
|
||||
for ($i = 0; $i < $stressTestCount; $i++) {
|
||||
$inputParams = [
|
||||
'W0' => rand(500, 3000),
|
||||
'H0' => rand(400, 2000),
|
||||
'screen_type' => rand(0, 1) ? 'SCREEN' : 'SLAT',
|
||||
'install_type' => ['WALL', 'SIDE', 'MIXED'][rand(0, 2)],
|
||||
'power_source' => ['AC', 'DC', 'MANUAL'][rand(0, 2)],
|
||||
];
|
||||
|
||||
$startTime = microtime(true);
|
||||
|
||||
try {
|
||||
$response = $this->postJson("/api/v1/design/models/{$kss01->id}/bom/resolve", [
|
||||
'parameters' => $inputParams
|
||||
]);
|
||||
|
||||
$executionTime = microtime(true) - $startTime;
|
||||
$totalTime += $executionTime;
|
||||
|
||||
if ($response->isSuccessful()) {
|
||||
$successCount++;
|
||||
} else {
|
||||
$errors[] = $response->status();
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$errors[] = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
$avgTime = $totalTime / $stressTestCount;
|
||||
$successRate = ($successCount / $stressTestCount) * 100;
|
||||
|
||||
// Stress test requirements
|
||||
$this->assertGreaterThanOrEqual(95, $successRate, 'Success rate too low under stress');
|
||||
$this->assertLessThan(1.0, $avgTime, 'Average response time too high under stress');
|
||||
|
||||
if (count($errors) > 0) {
|
||||
$this->addToAssertionCount(1); // Just to show we're tracking errors
|
||||
// Log errors for analysis
|
||||
error_log('Stress test errors: ' . json_encode(array_unique($errors)));
|
||||
}
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_monitors_database_query_performance()
|
||||
{
|
||||
$kss01 = Model::where('code', 'KSS01')->first();
|
||||
|
||||
$inputParams = [
|
||||
'W0' => 1000,
|
||||
'H0' => 800,
|
||||
'screen_type' => 'SCREEN',
|
||||
'install_type' => 'WALL',
|
||||
'power_source' => 'AC',
|
||||
];
|
||||
|
||||
// Enable query logging
|
||||
\DB::enableQueryLog();
|
||||
|
||||
$response = $this->postJson("/api/v1/design/models/{$kss01->id}/bom/resolve", [
|
||||
'parameters' => $inputParams
|
||||
]);
|
||||
|
||||
$queries = \DB::getQueryLog();
|
||||
\DB::disableQueryLog();
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
// Analyze query performance
|
||||
$queryCount = count($queries);
|
||||
$totalQueryTime = array_sum(array_column($queries, 'time'));
|
||||
|
||||
// Should not have excessive queries (N+1 problem)
|
||||
$this->assertLessThan(50, $queryCount, 'Too many database queries');
|
||||
|
||||
// Total query time should be reasonable
|
||||
$this->assertLessThan(500, $totalQueryTime, 'Database queries taking too long');
|
||||
|
||||
// Check for slow queries
|
||||
$slowQueries = array_filter($queries, fn($query) => $query['time'] > 100);
|
||||
$this->assertEmpty($slowQueries, 'Slow queries detected: ' . json_encode($slowQueries));
|
||||
}
|
||||
}
|
||||
251
tests/README.md
Normal file
251
tests/README.md
Normal file
@@ -0,0 +1,251 @@
|
||||
# Parametric BOM System Test Suite
|
||||
|
||||
This directory contains comprehensive test files for the parametric BOM system, including unit tests, integration tests, performance tests, and API validation.
|
||||
|
||||
## Test Structure
|
||||
|
||||
### PHPUnit Tests (`tests/Feature/Design/`)
|
||||
|
||||
Comprehensive PHPUnit feature tests for all Design domain components:
|
||||
|
||||
- **ModelParameterTest.php** - Tests parameter CRUD operations, validation, and type casting
|
||||
- **ModelFormulaTest.php** - Tests formula creation, calculation, and dependency resolution
|
||||
- **BomConditionRuleTest.php** - Tests condition rule evaluation and BOM manipulation
|
||||
- **BomResolverTest.php** - Tests complete BOM resolution workflows
|
||||
- **ProductFromModelTest.php** - Tests product creation from parametric models
|
||||
|
||||
### Database Seeders (`database/seeders/`)
|
||||
|
||||
Test data seeders for comprehensive testing:
|
||||
|
||||
- **ParametricBomSeeder.php** - Creates comprehensive test data with multiple models
|
||||
- **KSS01ModelSeeder.php** - Creates specific KSS01 model with realistic parameters
|
||||
|
||||
### Validation Scripts (`scripts/validation/`)
|
||||
|
||||
Standalone validation scripts for system testing:
|
||||
|
||||
- **validate_bom_system.php** - Complete system validation
|
||||
- **test_kss01_scenarios.php** - KSS01-specific business scenario testing
|
||||
- **performance_test.php** - Performance and scalability testing
|
||||
|
||||
### API Testing (`tests/postman/`)
|
||||
|
||||
Postman collection for API testing:
|
||||
|
||||
- **parametric_bom.postman_collection.json** - Complete API test collection
|
||||
- **parametric_bom.postman_environment.json** - Environment variables
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Prerequisites
|
||||
|
||||
1. **Seed Test Data**
|
||||
```bash
|
||||
php artisan db:seed --class=KSS01ModelSeeder
|
||||
# or for comprehensive test data:
|
||||
php artisan db:seed --class=ParametricBomSeeder
|
||||
```
|
||||
|
||||
2. **Ensure API Key Configuration**
|
||||
- Set up valid API keys in your environment
|
||||
- Configure test tenant and user credentials
|
||||
|
||||
### PHPUnit Tests
|
||||
|
||||
Run individual test suites:
|
||||
|
||||
```bash
|
||||
# All Design domain tests
|
||||
php artisan test tests/Feature/Design/
|
||||
|
||||
# Specific test classes
|
||||
php artisan test tests/Feature/Design/ModelParameterTest.php
|
||||
php artisan test tests/Feature/Design/BomResolverTest.php
|
||||
|
||||
# Run with coverage
|
||||
php artisan test --coverage-html coverage-report/
|
||||
```
|
||||
|
||||
### Validation Scripts
|
||||
|
||||
Run system validation scripts:
|
||||
|
||||
```bash
|
||||
# Complete system validation
|
||||
php scripts/validation/validate_bom_system.php
|
||||
|
||||
# KSS01 business scenarios
|
||||
php scripts/validation/test_kss01_scenarios.php
|
||||
|
||||
# Performance testing
|
||||
php scripts/validation/performance_test.php
|
||||
```
|
||||
|
||||
### Postman API Tests
|
||||
|
||||
1. Import the collection: `tests/postman/parametric_bom.postman_collection.json`
|
||||
2. Import the environment: `tests/postman/parametric_bom.postman_environment.json`
|
||||
3. Update environment variables:
|
||||
- `api_key` - Your API key
|
||||
- `user_email` - Test user email (default: demo@kss01.com)
|
||||
- `user_password` - Test user password (default: kss01demo)
|
||||
4. Run the collection
|
||||
|
||||
## Test Coverage
|
||||
|
||||
### Core Functionality
|
||||
|
||||
- ✅ Parameter validation and type checking
|
||||
- ✅ Formula calculation and dependency resolution
|
||||
- ✅ Condition rule evaluation and BOM manipulation
|
||||
- ✅ Complete BOM resolution workflows
|
||||
- ✅ Product creation from parametric models
|
||||
- ✅ Tenant isolation and security
|
||||
- ✅ Error handling and edge cases
|
||||
|
||||
### Business Scenarios
|
||||
|
||||
- ✅ Residential applications (small windows, patio doors)
|
||||
- ✅ Commercial applications (storefronts, office buildings)
|
||||
- ✅ Edge cases (minimum/maximum sizes, unusual dimensions)
|
||||
- ✅ Material type variations (fabric vs steel)
|
||||
- ✅ Installation type variations (wall/ceiling/recessed)
|
||||
|
||||
### Performance Testing
|
||||
|
||||
- ✅ Single BOM resolution performance
|
||||
- ✅ Batch resolution performance
|
||||
- ✅ Memory usage analysis
|
||||
- ✅ Database query efficiency
|
||||
- ✅ Concurrent operation simulation
|
||||
- ✅ Large dataset throughput
|
||||
|
||||
### API Validation
|
||||
|
||||
- ✅ Authentication and authorization
|
||||
- ✅ CRUD operations for all entities
|
||||
- ✅ BOM resolution workflows
|
||||
- ✅ Error handling and validation
|
||||
- ✅ Performance benchmarks
|
||||
|
||||
## Performance Targets
|
||||
|
||||
| Metric | Target | Test Location |
|
||||
|--------|--------|---------------|
|
||||
| Single BOM Resolution | < 200ms | performance_test.php |
|
||||
| Batch 10 Resolutions | < 1.5s | performance_test.php |
|
||||
| Batch 100 Resolutions | < 12s | performance_test.php |
|
||||
| Memory Usage | < 50MB | performance_test.php |
|
||||
| DB Queries per Resolution | < 20 | performance_test.php |
|
||||
| Throughput | ≥ 10/sec | performance_test.php |
|
||||
|
||||
## Quality Gates
|
||||
|
||||
### System Validation Success Criteria
|
||||
|
||||
- ✅ ≥90% test pass rate = Production Ready
|
||||
- ⚠️ 75-89% test pass rate = Review Required
|
||||
- ❌ <75% test pass rate = Not Ready
|
||||
|
||||
### Business Scenario Success Criteria
|
||||
|
||||
- ✅ ≥95% scenario pass rate = Business Logic Validated
|
||||
- ⚠️ 85-94% scenario pass rate = Edge Cases Need Review
|
||||
- ❌ <85% scenario pass rate = Critical Issues
|
||||
|
||||
### Performance Success Criteria
|
||||
|
||||
- ✅ ≥90% performance tests pass = Performance Requirements Met
|
||||
- ⚠️ 70-89% performance tests pass = Performance Issues Detected
|
||||
- ❌ <70% performance tests pass = Critical Performance Issues
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **"KSS_DEMO tenant not found"**
|
||||
- Run KSS01ModelSeeder: `php artisan db:seed --class=KSS01ModelSeeder`
|
||||
|
||||
2. **API key authentication failures**
|
||||
- Verify API key is correctly set in environment
|
||||
- Check API key middleware configuration
|
||||
|
||||
3. **Test database issues**
|
||||
- Ensure test database is properly configured
|
||||
- Run migrations: `php artisan migrate --env=testing`
|
||||
|
||||
4. **Performance test failures**
|
||||
- Check database indexes are created
|
||||
- Verify system resources (CPU, memory)
|
||||
- Review query optimization
|
||||
|
||||
### Debugging Tips
|
||||
|
||||
1. **Enable Query Logging**
|
||||
```php
|
||||
DB::enableQueryLog();
|
||||
// Run operations
|
||||
$queries = DB::getQueryLog();
|
||||
```
|
||||
|
||||
2. **Check Memory Usage**
|
||||
```php
|
||||
echo memory_get_usage(true) / 1024 / 1024 . " MB\n";
|
||||
echo memory_get_peak_usage(true) / 1024 / 1024 . " MB peak\n";
|
||||
```
|
||||
|
||||
3. **Profile Performance**
|
||||
```bash
|
||||
php -d xdebug.profiler_enable=1 scripts/validation/performance_test.php
|
||||
```
|
||||
|
||||
## Continuous Integration
|
||||
|
||||
### GitHub Actions / CI Pipeline
|
||||
|
||||
```yaml
|
||||
# Example CI configuration
|
||||
- name: Run PHPUnit Tests
|
||||
run: php artisan test --coverage-clover coverage.xml
|
||||
|
||||
- name: Run System Validation
|
||||
run: php scripts/validation/validate_bom_system.php
|
||||
|
||||
- name: Run Performance Tests
|
||||
run: php scripts/validation/performance_test.php
|
||||
|
||||
- name: Check Coverage
|
||||
run: |
|
||||
if [ $(php -r "echo coverage_percentage();") -lt 80 ]; then
|
||||
echo "Coverage below 80%"
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
### Quality Metrics
|
||||
|
||||
- **Code Coverage**: Minimum 80% line coverage
|
||||
- **Test Pass Rate**: Minimum 95% pass rate
|
||||
- **Performance**: All performance targets met
|
||||
- **Security**: No security vulnerabilities in tests
|
||||
|
||||
## Contributing
|
||||
|
||||
When adding new tests:
|
||||
|
||||
1. Follow existing naming conventions
|
||||
2. Include both positive and negative test cases
|
||||
3. Add performance considerations for new features
|
||||
4. Update this README with new test documentation
|
||||
5. Ensure tenant isolation in all tests
|
||||
6. Include edge case testing
|
||||
|
||||
## Support
|
||||
|
||||
For test-related issues:
|
||||
|
||||
1. Check logs in `storage/logs/laravel.log`
|
||||
2. Review test output for specific failure details
|
||||
3. Verify test data seeding completed successfully
|
||||
4. Check database connection and permissions
|
||||
414
tests/Security/ApiSecurityTest.php
Normal file
414
tests/Security/ApiSecurityTest.php
Normal file
@@ -0,0 +1,414 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Security;
|
||||
|
||||
use App\Models\Model;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ApiSecurityTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private User $user;
|
||||
private User $otherTenantUser;
|
||||
private Model $model;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Create users in different tenants
|
||||
$this->user = User::factory()->create(['tenant_id' => 1]);
|
||||
$this->otherTenantUser = User::factory()->create(['tenant_id' => 2]);
|
||||
|
||||
// Create test model
|
||||
$this->model = Model::factory()->create(['tenant_id' => 1]);
|
||||
|
||||
// Set required headers
|
||||
$this->withHeaders([
|
||||
'X-API-KEY' => config('app.api_key', 'test-api-key'),
|
||||
'Accept' => 'application/json',
|
||||
'Content-Type' => 'application/json',
|
||||
]);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_requires_api_key_for_all_endpoints()
|
||||
{
|
||||
// Remove API key header
|
||||
$this->withHeaders([
|
||||
'Accept' => 'application/json',
|
||||
'Content-Type' => 'application/json',
|
||||
]);
|
||||
|
||||
// Test various endpoints
|
||||
$endpoints = [
|
||||
['GET', '/api/v1/design/models'],
|
||||
['GET', "/api/v1/design/models/{$this->model->id}/parameters"],
|
||||
['POST', "/api/v1/design/models/{$this->model->id}/bom/resolve"],
|
||||
];
|
||||
|
||||
foreach ($endpoints as [$method, $endpoint]) {
|
||||
$response = $this->json($method, $endpoint);
|
||||
$response->assertUnauthorized();
|
||||
}
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_rejects_invalid_api_keys()
|
||||
{
|
||||
$this->withHeaders([
|
||||
'X-API-KEY' => 'invalid-api-key',
|
||||
'Accept' => 'application/json',
|
||||
]);
|
||||
|
||||
$response = $this->getJson('/api/v1/design/models');
|
||||
$response->assertUnauthorized();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_requires_authentication_for_protected_routes()
|
||||
{
|
||||
// Test endpoints that require user authentication
|
||||
$protectedEndpoints = [
|
||||
['GET', "/api/v1/design/models/{$this->model->id}/parameters"],
|
||||
['POST', "/api/v1/design/models/{$this->model->id}/parameters"],
|
||||
['POST', "/api/v1/design/models/{$this->model->id}/bom/resolve"],
|
||||
['POST', "/api/v1/design/models/{$this->model->id}/products"],
|
||||
];
|
||||
|
||||
foreach ($protectedEndpoints as [$method, $endpoint]) {
|
||||
$response = $this->json($method, $endpoint);
|
||||
$response->assertUnauthorized();
|
||||
}
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_enforces_tenant_isolation()
|
||||
{
|
||||
// Authenticate as user from tenant 1
|
||||
Sanctum::actingAs($this->user);
|
||||
|
||||
// Try to access model from different tenant
|
||||
$otherTenantModel = Model::factory()->create(['tenant_id' => 2]);
|
||||
|
||||
$response = $this->getJson("/api/v1/design/models/{$otherTenantModel->id}/parameters");
|
||||
$response->assertNotFound();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_prevents_sql_injection_in_parameters()
|
||||
{
|
||||
Sanctum::actingAs($this->user);
|
||||
|
||||
// Test SQL injection attempts in various inputs
|
||||
$sqlInjectionPayloads = [
|
||||
"'; DROP TABLE models; --",
|
||||
"' UNION SELECT * FROM users --",
|
||||
"1' OR '1'='1",
|
||||
"<script>alert('xss')</script>",
|
||||
];
|
||||
|
||||
foreach ($sqlInjectionPayloads as $payload) {
|
||||
// Test in BOM resolution parameters
|
||||
$response = $this->postJson("/api/v1/design/models/{$this->model->id}/bom/resolve", [
|
||||
'parameters' => [
|
||||
'W0' => $payload,
|
||||
'H0' => 800,
|
||||
'screen_type' => 'SCREEN',
|
||||
]
|
||||
]);
|
||||
|
||||
// Should either validate and reject, or handle safely
|
||||
$this->assertTrue(
|
||||
$response->status() === 422 || $response->status() === 400,
|
||||
"SQL injection payload was not properly handled: {$payload}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_sanitizes_formula_expressions()
|
||||
{
|
||||
Sanctum::actingAs($this->user);
|
||||
|
||||
// Test dangerous expressions that could execute arbitrary code
|
||||
$dangerousExpressions = [
|
||||
'system("rm -rf /")',
|
||||
'eval("malicious code")',
|
||||
'exec("ls -la")',
|
||||
'__import__("os").system("pwd")',
|
||||
'file_get_contents("/etc/passwd")',
|
||||
];
|
||||
|
||||
foreach ($dangerousExpressions as $expression) {
|
||||
$response = $this->postJson("/api/v1/design/models/{$this->model->id}/formulas", [
|
||||
'name' => 'test_formula',
|
||||
'expression' => $expression,
|
||||
'description' => 'Test formula',
|
||||
'return_type' => 'NUMBER',
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
// Should reject dangerous expressions
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors(['expression']);
|
||||
}
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_prevents_xss_in_user_inputs()
|
||||
{
|
||||
Sanctum::actingAs($this->user);
|
||||
|
||||
$xssPayloads = [
|
||||
'<script>alert("xss")</script>',
|
||||
'javascript:alert("xss")',
|
||||
'<img src="x" onerror="alert(1)">',
|
||||
'<svg onload="alert(1)">',
|
||||
];
|
||||
|
||||
foreach ($xssPayloads as $payload) {
|
||||
$response = $this->postJson("/api/v1/design/models/{$this->model->id}/parameters", [
|
||||
'name' => 'test_param',
|
||||
'label' => $payload,
|
||||
'type' => 'NUMBER',
|
||||
'default_value' => '0',
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
// Check that XSS payload is not reflected in response
|
||||
if ($response->isSuccessful()) {
|
||||
$responseData = $response->json();
|
||||
$this->assertStringNotContainsString('<script>', json_encode($responseData));
|
||||
$this->assertStringNotContainsString('javascript:', json_encode($responseData));
|
||||
$this->assertStringNotContainsString('onerror=', json_encode($responseData));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_rate_limits_api_requests()
|
||||
{
|
||||
Sanctum::actingAs($this->user);
|
||||
|
||||
$endpoint = "/api/v1/design/models/{$this->model->id}/parameters";
|
||||
$successfulRequests = 0;
|
||||
$rateLimitHit = false;
|
||||
|
||||
// Make many requests quickly
|
||||
for ($i = 0; $i < 100; $i++) {
|
||||
$response = $this->getJson($endpoint);
|
||||
|
||||
if ($response->status() === 429) {
|
||||
$rateLimitHit = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if ($response->isSuccessful()) {
|
||||
$successfulRequests++;
|
||||
}
|
||||
}
|
||||
|
||||
// Should hit rate limit before 100 requests
|
||||
$this->assertTrue($rateLimitHit || $successfulRequests < 100, 'Rate limiting is not working properly');
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_validates_file_uploads_securely()
|
||||
{
|
||||
Sanctum::actingAs($this->user);
|
||||
|
||||
// Test malicious file uploads
|
||||
$maliciousFiles = [
|
||||
// PHP script disguised as image
|
||||
[
|
||||
'name' => 'image.php.jpg',
|
||||
'content' => '<?php system($_GET["cmd"]); ?>',
|
||||
'mime' => 'image/jpeg',
|
||||
],
|
||||
// Executable file
|
||||
[
|
||||
'name' => 'malware.exe',
|
||||
'content' => 'MZ...', // PE header
|
||||
'mime' => 'application/octet-stream',
|
||||
],
|
||||
// Script with dangerous extension
|
||||
[
|
||||
'name' => 'script.js',
|
||||
'content' => 'alert("xss")',
|
||||
'mime' => 'application/javascript',
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($maliciousFiles as $file) {
|
||||
$uploadedFile = \Illuminate\Http\UploadedFile::fake()->createWithContent(
|
||||
$file['name'],
|
||||
$file['content']
|
||||
);
|
||||
|
||||
$response = $this->postJson('/api/v1/files/upload', [
|
||||
'file' => $uploadedFile,
|
||||
'type' => 'model_attachment',
|
||||
]);
|
||||
|
||||
// Should reject malicious files
|
||||
$this->assertTrue(
|
||||
$response->status() === 422 || $response->status() === 400,
|
||||
"Malicious file was not rejected: {$file['name']}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_prevents_mass_assignment_vulnerabilities()
|
||||
{
|
||||
Sanctum::actingAs($this->user);
|
||||
|
||||
// Try to mass assign protected fields
|
||||
$response = $this->postJson("/api/v1/design/models/{$this->model->id}/parameters", [
|
||||
'name' => 'test_param',
|
||||
'label' => 'Test Parameter',
|
||||
'type' => 'NUMBER',
|
||||
'tenant_id' => 999, // Should not be mass assignable
|
||||
'created_by' => 999, // Should not be mass assignable
|
||||
'id' => 999, // Should not be mass assignable
|
||||
]);
|
||||
|
||||
if ($response->isSuccessful()) {
|
||||
$parameter = $response->json('data');
|
||||
|
||||
// These fields should not be affected by mass assignment
|
||||
$this->assertEquals(1, $parameter['tenant_id']); // Should use authenticated user's tenant
|
||||
$this->assertEquals($this->user->id, $parameter['created_by']); // Should use authenticated user
|
||||
$this->assertNotEquals(999, $parameter['id']); // Should be auto-generated
|
||||
}
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_handles_concurrent_requests_safely()
|
||||
{
|
||||
Sanctum::actingAs($this->user);
|
||||
|
||||
// Simulate concurrent creation of parameters
|
||||
$promises = [];
|
||||
for ($i = 0; $i < 10; $i++) {
|
||||
$promises[] = $this->postJson("/api/v1/design/models/{$this->model->id}/parameters", [
|
||||
'name' => "concurrent_param_{$i}",
|
||||
'label' => "Concurrent Parameter {$i}",
|
||||
'type' => 'NUMBER',
|
||||
'sort_order' => $i,
|
||||
]);
|
||||
}
|
||||
|
||||
// All requests should be handled without errors
|
||||
foreach ($promises as $response) {
|
||||
$this->assertTrue($response->isSuccessful() || $response->status() === 422);
|
||||
}
|
||||
|
||||
// Check for race conditions in database
|
||||
$parameters = \App\Models\ModelParameter::where('model_id', $this->model->id)->get();
|
||||
$sortOrders = $parameters->pluck('sort_order')->toArray();
|
||||
|
||||
// Should not have duplicate sort orders if handling concurrency properly
|
||||
$this->assertEquals(count($sortOrders), count(array_unique($sortOrders)));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_logs_security_events()
|
||||
{
|
||||
// Test that security events are properly logged
|
||||
$this->withHeaders([
|
||||
'X-API-KEY' => 'invalid-api-key',
|
||||
]);
|
||||
|
||||
$response = $this->getJson('/api/v1/design/models');
|
||||
$response->assertUnauthorized();
|
||||
|
||||
// Check that failed authentication is logged
|
||||
$this->assertDatabaseHas('audit_logs', [
|
||||
'action' => 'authentication_failed',
|
||||
'ip' => request()->ip(),
|
||||
]);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_protects_against_timing_attacks()
|
||||
{
|
||||
// Test that authentication timing is consistent
|
||||
$validKey = config('app.api_key');
|
||||
$invalidKey = 'invalid-key-with-same-length-as-valid-key';
|
||||
|
||||
$validKeyTimes = [];
|
||||
$invalidKeyTimes = [];
|
||||
|
||||
// Measure timing for valid key
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
$start = microtime(true);
|
||||
$this->withHeaders(['X-API-KEY' => $validKey])
|
||||
->getJson('/api/v1/design/models');
|
||||
$validKeyTimes[] = microtime(true) - $start;
|
||||
}
|
||||
|
||||
// Measure timing for invalid key
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
$start = microtime(true);
|
||||
$this->withHeaders(['X-API-KEY' => $invalidKey])
|
||||
->getJson('/api/v1/design/models');
|
||||
$invalidKeyTimes[] = microtime(true) - $start;
|
||||
}
|
||||
|
||||
$avgValidTime = array_sum($validKeyTimes) / count($validKeyTimes);
|
||||
$avgInvalidTime = array_sum($invalidKeyTimes) / count($invalidKeyTimes);
|
||||
|
||||
// Timing difference should not be significant (within 50ms)
|
||||
$timingDifference = abs($avgValidTime - $avgInvalidTime);
|
||||
$this->assertLessThan(0.05, $timingDifference, 'Timing attack vulnerability detected');
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_validates_formula_complexity()
|
||||
{
|
||||
Sanctum::actingAs($this->user);
|
||||
|
||||
// Test extremely complex formulas that could cause DoS
|
||||
$complexFormula = str_repeat('(', 1000) . 'W0' . str_repeat(')', 1000);
|
||||
|
||||
$response = $this->postJson("/api/v1/design/models/{$this->model->id}/formulas", [
|
||||
'name' => 'complex_formula',
|
||||
'expression' => $complexFormula,
|
||||
'description' => 'Overly complex formula',
|
||||
'return_type' => 'NUMBER',
|
||||
]);
|
||||
|
||||
// Should reject overly complex formulas
|
||||
$response->assertStatus(422);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_prevents_path_traversal_attacks()
|
||||
{
|
||||
Sanctum::actingAs($this->user);
|
||||
|
||||
// Test path traversal in file operations
|
||||
$pathTraversalPayloads = [
|
||||
'../../../etc/passwd',
|
||||
'..\\..\\..\\windows\\system32\\config\\sam',
|
||||
'%2e%2e%2f%2e%2e%2f%2e%2e%2f',
|
||||
'....//....//....//etc/passwd',
|
||||
];
|
||||
|
||||
foreach ($pathTraversalPayloads as $payload) {
|
||||
$response = $this->getJson("/api/v1/files/{$payload}");
|
||||
|
||||
// Should not allow path traversal
|
||||
$this->assertTrue(
|
||||
$response->status() === 404 || $response->status() === 400,
|
||||
"Path traversal not prevented for: {$payload}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
436
tests/Unit/BomConditionRuleServiceTest.php
Normal file
436
tests/Unit/BomConditionRuleServiceTest.php
Normal file
@@ -0,0 +1,436 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Models\BomConditionRule;
|
||||
use App\Models\Model;
|
||||
use App\Services\BomConditionRuleService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class BomConditionRuleServiceTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private BomConditionRuleService $service;
|
||||
private Model $model;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->service = new BomConditionRuleService();
|
||||
$this->service->setTenantId(1)->setApiUserId(1);
|
||||
|
||||
$this->model = Model::factory()->screen()->create();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_can_get_all_rules_for_model()
|
||||
{
|
||||
// Arrange
|
||||
BomConditionRule::factory()
|
||||
->count(3)
|
||||
->create(['model_id' => $this->model->id]);
|
||||
|
||||
// Act
|
||||
$result = $this->service->getRulesByModel($this->model->id);
|
||||
|
||||
// Assert
|
||||
$this->assertCount(3, $result);
|
||||
$this->assertEquals($this->model->id, $result->first()->model_id);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_filters_inactive_rules()
|
||||
{
|
||||
// Arrange
|
||||
BomConditionRule::factory()
|
||||
->active()
|
||||
->create(['model_id' => $this->model->id]);
|
||||
BomConditionRule::factory()
|
||||
->inactive()
|
||||
->create(['model_id' => $this->model->id]);
|
||||
|
||||
// Act
|
||||
$result = $this->service->getRulesByModel($this->model->id);
|
||||
|
||||
// Assert
|
||||
$this->assertCount(1, $result);
|
||||
$this->assertTrue($result->first()->is_active);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_orders_rules_by_priority()
|
||||
{
|
||||
// Arrange
|
||||
BomConditionRule::factory()
|
||||
->create(['model_id' => $this->model->id, 'priority' => 30, 'name' => 'low']);
|
||||
BomConditionRule::factory()
|
||||
->create(['model_id' => $this->model->id, 'priority' => 10, 'name' => 'high']);
|
||||
BomConditionRule::factory()
|
||||
->create(['model_id' => $this->model->id, 'priority' => 20, 'name' => 'medium']);
|
||||
|
||||
// Act
|
||||
$result = $this->service->getRulesByModel($this->model->id);
|
||||
|
||||
// Assert
|
||||
$this->assertEquals('high', $result->get(0)->name);
|
||||
$this->assertEquals('medium', $result->get(1)->name);
|
||||
$this->assertEquals('low', $result->get(2)->name);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_can_create_rule()
|
||||
{
|
||||
// Arrange
|
||||
$data = [
|
||||
'name' => 'Test Rule',
|
||||
'description' => 'Test description',
|
||||
'condition_expression' => 'area > 5',
|
||||
'component_code' => 'TEST-001',
|
||||
'quantity_expression' => '2',
|
||||
'priority' => 50,
|
||||
];
|
||||
|
||||
// Act
|
||||
$result = $this->service->createRule($this->model->id, $data);
|
||||
|
||||
// Assert
|
||||
$this->assertInstanceOf(BomConditionRule::class, $result);
|
||||
$this->assertEquals('Test Rule', $result->name);
|
||||
$this->assertEquals('area > 5', $result->condition_expression);
|
||||
$this->assertEquals('TEST-001', $result->component_code);
|
||||
$this->assertEquals($this->model->id, $result->model_id);
|
||||
$this->assertEquals(1, $result->tenant_id);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_can_update_rule()
|
||||
{
|
||||
// Arrange
|
||||
$rule = BomConditionRule::factory()
|
||||
->create(['model_id' => $this->model->id, 'name' => 'old_name']);
|
||||
|
||||
$updateData = [
|
||||
'name' => 'new_name',
|
||||
'condition_expression' => 'area <= 10',
|
||||
'priority' => 100,
|
||||
];
|
||||
|
||||
// Act
|
||||
$result = $this->service->updateRule($rule->id, $updateData);
|
||||
|
||||
// Assert
|
||||
$this->assertEquals('new_name', $result->name);
|
||||
$this->assertEquals('area <= 10', $result->condition_expression);
|
||||
$this->assertEquals(100, $result->priority);
|
||||
$this->assertEquals(1, $result->updated_by);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_can_delete_rule()
|
||||
{
|
||||
// Arrange
|
||||
$rule = BomConditionRule::factory()
|
||||
->create(['model_id' => $this->model->id]);
|
||||
|
||||
// Act
|
||||
$result = $this->service->deleteRule($rule->id);
|
||||
|
||||
// Assert
|
||||
$this->assertTrue($result);
|
||||
$this->assertSoftDeleted('bom_condition_rules', ['id' => $rule->id]);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_evaluates_simple_conditions()
|
||||
{
|
||||
// Arrange
|
||||
$rule = BomConditionRule::factory()
|
||||
->create([
|
||||
'model_id' => $this->model->id,
|
||||
'condition_expression' => 'area > 5',
|
||||
'component_code' => 'LARGE-CASE',
|
||||
'quantity_expression' => '1',
|
||||
]);
|
||||
|
||||
// Test cases
|
||||
$testCases = [
|
||||
['area' => 3, 'expected' => false],
|
||||
['area' => 6, 'expected' => true],
|
||||
['area' => 5, 'expected' => false], // Exactly 5 should be false for > 5
|
||||
];
|
||||
|
||||
foreach ($testCases as $testCase) {
|
||||
// Act
|
||||
$result = $this->service->evaluateCondition($rule, $testCase);
|
||||
|
||||
// Assert
|
||||
$this->assertEquals($testCase['expected'], $result);
|
||||
}
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_evaluates_complex_conditions()
|
||||
{
|
||||
// Arrange
|
||||
$rule = BomConditionRule::factory()
|
||||
->create([
|
||||
'model_id' => $this->model->id,
|
||||
'condition_expression' => 'area > 3 AND screen_type = "SCREEN"',
|
||||
'component_code' => 'SCREEN-CASE',
|
||||
'quantity_expression' => '1',
|
||||
]);
|
||||
|
||||
// Test cases
|
||||
$testCases = [
|
||||
['area' => 5, 'screen_type' => 'SCREEN', 'expected' => true],
|
||||
['area' => 5, 'screen_type' => 'SLAT', 'expected' => false],
|
||||
['area' => 2, 'screen_type' => 'SCREEN', 'expected' => false],
|
||||
['area' => 2, 'screen_type' => 'SLAT', 'expected' => false],
|
||||
];
|
||||
|
||||
foreach ($testCases as $testCase) {
|
||||
// Act
|
||||
$result = $this->service->evaluateCondition($rule, $testCase);
|
||||
|
||||
// Assert
|
||||
$this->assertEquals($testCase['expected'], $result,
|
||||
"Failed for area={$testCase['area']}, screen_type={$testCase['screen_type']}");
|
||||
}
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_evaluates_quantity_expressions()
|
||||
{
|
||||
// Test simple quantity
|
||||
$rule1 = BomConditionRule::factory()
|
||||
->create([
|
||||
'model_id' => $this->model->id,
|
||||
'quantity_expression' => '2',
|
||||
]);
|
||||
|
||||
$result1 = $this->service->evaluateQuantity($rule1, []);
|
||||
$this->assertEquals(2, $result1);
|
||||
|
||||
// Test calculated quantity
|
||||
$rule2 = BomConditionRule::factory()
|
||||
->create([
|
||||
'model_id' => $this->model->id,
|
||||
'quantity_expression' => 'CEIL(W1 / 1000)',
|
||||
]);
|
||||
|
||||
$result2 = $this->service->evaluateQuantity($rule2, ['W1' => 2500]);
|
||||
$this->assertEquals(3, $result2); // ceil(2500/1000) = 3
|
||||
|
||||
// Test formula-based quantity
|
||||
$rule3 = BomConditionRule::factory()
|
||||
->create([
|
||||
'model_id' => $this->model->id,
|
||||
'quantity_expression' => 'W1 / 1000',
|
||||
]);
|
||||
|
||||
$result3 = $this->service->evaluateQuantity($rule3, ['W1' => 1500]);
|
||||
$this->assertEquals(1.5, $result3);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_applies_rules_in_priority_order()
|
||||
{
|
||||
// Arrange - Create rules with different priorities
|
||||
$rules = [
|
||||
BomConditionRule::factory()
|
||||
->create([
|
||||
'model_id' => $this->model->id,
|
||||
'name' => 'High Priority Rule',
|
||||
'condition_expression' => 'TRUE',
|
||||
'component_code' => 'HIGH-PRIORITY',
|
||||
'quantity_expression' => '1',
|
||||
'priority' => 10,
|
||||
]),
|
||||
BomConditionRule::factory()
|
||||
->create([
|
||||
'model_id' => $this->model->id,
|
||||
'name' => 'Low Priority Rule',
|
||||
'condition_expression' => 'TRUE',
|
||||
'component_code' => 'LOW-PRIORITY',
|
||||
'quantity_expression' => '1',
|
||||
'priority' => 50,
|
||||
]),
|
||||
BomConditionRule::factory()
|
||||
->create([
|
||||
'model_id' => $this->model->id,
|
||||
'name' => 'Medium Priority Rule',
|
||||
'condition_expression' => 'TRUE',
|
||||
'component_code' => 'MEDIUM-PRIORITY',
|
||||
'quantity_expression' => '1',
|
||||
'priority' => 30,
|
||||
]),
|
||||
];
|
||||
|
||||
// Act
|
||||
$appliedRules = $this->service->applyRules($this->model->id, []);
|
||||
|
||||
// Assert
|
||||
$this->assertCount(3, $appliedRules);
|
||||
$this->assertEquals('HIGH-PRIORITY', $appliedRules[0]['component_code']);
|
||||
$this->assertEquals('MEDIUM-PRIORITY', $appliedRules[1]['component_code']);
|
||||
$this->assertEquals('LOW-PRIORITY', $appliedRules[2]['component_code']);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_skips_rules_with_false_conditions()
|
||||
{
|
||||
// Arrange
|
||||
BomConditionRule::factory()
|
||||
->create([
|
||||
'model_id' => $this->model->id,
|
||||
'name' => 'Should Apply',
|
||||
'condition_expression' => 'area > 3',
|
||||
'component_code' => 'SHOULD-APPLY',
|
||||
'quantity_expression' => '1',
|
||||
'priority' => 10,
|
||||
]);
|
||||
|
||||
BomConditionRule::factory()
|
||||
->create([
|
||||
'model_id' => $this->model->id,
|
||||
'name' => 'Should Skip',
|
||||
'condition_expression' => 'area <= 3',
|
||||
'component_code' => 'SHOULD-SKIP',
|
||||
'quantity_expression' => '1',
|
||||
'priority' => 20,
|
||||
]);
|
||||
|
||||
// Act
|
||||
$appliedRules = $this->service->applyRules($this->model->id, ['area' => 5]);
|
||||
|
||||
// Assert
|
||||
$this->assertCount(1, $appliedRules);
|
||||
$this->assertEquals('SHOULD-APPLY', $appliedRules[0]['component_code']);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_validates_condition_syntax()
|
||||
{
|
||||
// Test valid conditions
|
||||
$validConditions = [
|
||||
'TRUE',
|
||||
'FALSE',
|
||||
'area > 5',
|
||||
'area >= 5 AND W0 < 2000',
|
||||
'screen_type = "SCREEN"',
|
||||
'install_type != "WALL"',
|
||||
'(area > 3 AND screen_type = "SCREEN") OR install_type = "SIDE"',
|
||||
];
|
||||
|
||||
foreach ($validConditions as $condition) {
|
||||
$isValid = $this->service->validateConditionSyntax($condition);
|
||||
$this->assertTrue($isValid, "Condition should be valid: {$condition}");
|
||||
}
|
||||
|
||||
// Test invalid conditions
|
||||
$invalidConditions = [
|
||||
'area > > 5', // Double operator
|
||||
'area AND', // Incomplete expression
|
||||
'unknown_var > 5', // Unknown variable (if validation is strict)
|
||||
'area = "invalid"', // Type mismatch
|
||||
'', // Empty condition
|
||||
];
|
||||
|
||||
foreach ($invalidConditions as $condition) {
|
||||
$isValid = $this->service->validateConditionSyntax($condition);
|
||||
$this->assertFalse($isValid, "Condition should be invalid: {$condition}");
|
||||
}
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_respects_tenant_isolation()
|
||||
{
|
||||
// Arrange
|
||||
$otherTenantModel = Model::factory()->create(['tenant_id' => 2]);
|
||||
BomConditionRule::factory()
|
||||
->create(['model_id' => $otherTenantModel->id, 'tenant_id' => 2]);
|
||||
|
||||
// Act
|
||||
$result = $this->service->getRulesByModel($otherTenantModel->id);
|
||||
|
||||
// Assert
|
||||
$this->assertCount(0, $result);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_can_bulk_update_rules()
|
||||
{
|
||||
// Arrange
|
||||
$rules = BomConditionRule::factory()
|
||||
->count(3)
|
||||
->create(['model_id' => $this->model->id]);
|
||||
|
||||
$updateData = [
|
||||
[
|
||||
'id' => $rules[0]->id,
|
||||
'name' => 'updated_rule_1',
|
||||
'priority' => 5,
|
||||
],
|
||||
[
|
||||
'id' => $rules[1]->id,
|
||||
'condition_expression' => 'area > 10',
|
||||
'priority' => 15,
|
||||
],
|
||||
[
|
||||
'id' => $rules[2]->id,
|
||||
'is_active' => false,
|
||||
],
|
||||
];
|
||||
|
||||
// Act
|
||||
$result = $this->service->bulkUpdateRules($this->model->id, $updateData);
|
||||
|
||||
// Assert
|
||||
$this->assertTrue($result);
|
||||
|
||||
$updated = BomConditionRule::whereIn('id', $rules->pluck('id'))->get();
|
||||
$this->assertEquals('updated_rule_1', $updated->where('id', $rules[0]->id)->first()->name);
|
||||
$this->assertEquals('area > 10', $updated->where('id', $rules[1]->id)->first()->condition_expression);
|
||||
$this->assertFalse($updated->where('id', $rules[2]->id)->first()->is_active);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_handles_complex_kss01_scenario()
|
||||
{
|
||||
// Arrange - Create KSS01 rules
|
||||
$rules = BomConditionRule::factory()
|
||||
->screenRules()
|
||||
->create(['model_id' => $this->model->id]);
|
||||
|
||||
// Small screen test case
|
||||
$smallScreenParams = [
|
||||
'W0' => 1000,
|
||||
'H0' => 800,
|
||||
'W1' => 1120,
|
||||
'H1' => 900,
|
||||
'area' => 1.008,
|
||||
'screen_type' => 'SCREEN',
|
||||
];
|
||||
|
||||
// Act
|
||||
$appliedRules = $this->service->applyRules($this->model->id, $smallScreenParams);
|
||||
|
||||
// Assert
|
||||
$this->assertGreaterThan(0, count($appliedRules));
|
||||
|
||||
// Check that case rule is applied correctly (small case for area <= 3)
|
||||
$caseRule = collect($appliedRules)->first(fn($rule) => str_contains($rule['component_code'], 'CASE'));
|
||||
$this->assertNotNull($caseRule);
|
||||
$this->assertEquals('CASE-SMALL', $caseRule['component_code']);
|
||||
|
||||
// Check that screen-specific pipe is applied
|
||||
$pipeRule = collect($appliedRules)->first(fn($rule) => $rule['component_code'] === 'PIPE-SCREEN');
|
||||
$this->assertNotNull($pipeRule);
|
||||
|
||||
// Check that slat-specific pipe is NOT applied
|
||||
$slatPipeRule = collect($appliedRules)->first(fn($rule) => $rule['component_code'] === 'PIPE-SLAT');
|
||||
$this->assertNull($slatPipeRule);
|
||||
}
|
||||
}
|
||||
383
tests/Unit/ModelFormulaServiceTest.php
Normal file
383
tests/Unit/ModelFormulaServiceTest.php
Normal file
@@ -0,0 +1,383 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Models\Model;
|
||||
use App\Models\ModelFormula;
|
||||
use App\Models\ModelParameter;
|
||||
use App\Services\ModelFormulaService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ModelFormulaServiceTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private ModelFormulaService $service;
|
||||
private Model $model;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->service = new ModelFormulaService();
|
||||
$this->service->setTenantId(1)->setApiUserId(1);
|
||||
|
||||
$this->model = Model::factory()->screen()->create();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_can_get_all_formulas_for_model()
|
||||
{
|
||||
// Arrange
|
||||
ModelFormula::factory()
|
||||
->count(3)
|
||||
->create(['model_id' => $this->model->id]);
|
||||
|
||||
// Act
|
||||
$result = $this->service->getFormulasByModel($this->model->id);
|
||||
|
||||
// Assert
|
||||
$this->assertCount(3, $result);
|
||||
$this->assertEquals($this->model->id, $result->first()->model_id);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_filters_inactive_formulas()
|
||||
{
|
||||
// Arrange
|
||||
ModelFormula::factory()
|
||||
->create(['model_id' => $this->model->id, 'is_active' => true]);
|
||||
ModelFormula::factory()
|
||||
->create(['model_id' => $this->model->id, 'is_active' => false]);
|
||||
|
||||
// Act
|
||||
$result = $this->service->getFormulasByModel($this->model->id);
|
||||
|
||||
// Assert
|
||||
$this->assertCount(1, $result);
|
||||
$this->assertTrue($result->first()->is_active);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_orders_formulas_by_sort_order()
|
||||
{
|
||||
// Arrange
|
||||
ModelFormula::factory()
|
||||
->create(['model_id' => $this->model->id, 'sort_order' => 3, 'name' => 'third']);
|
||||
ModelFormula::factory()
|
||||
->create(['model_id' => $this->model->id, 'sort_order' => 1, 'name' => 'first']);
|
||||
ModelFormula::factory()
|
||||
->create(['model_id' => $this->model->id, 'sort_order' => 2, 'name' => 'second']);
|
||||
|
||||
// Act
|
||||
$result = $this->service->getFormulasByModel($this->model->id);
|
||||
|
||||
// Assert
|
||||
$this->assertEquals('first', $result->get(0)->name);
|
||||
$this->assertEquals('second', $result->get(1)->name);
|
||||
$this->assertEquals('third', $result->get(2)->name);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_can_create_formula()
|
||||
{
|
||||
// Arrange
|
||||
$data = [
|
||||
'name' => 'test_formula',
|
||||
'expression' => 'W0 * H0',
|
||||
'description' => 'Test calculation',
|
||||
'return_type' => 'NUMBER',
|
||||
'sort_order' => 1,
|
||||
];
|
||||
|
||||
// Act
|
||||
$result = $this->service->createFormula($this->model->id, $data);
|
||||
|
||||
// Assert
|
||||
$this->assertInstanceOf(ModelFormula::class, $result);
|
||||
$this->assertEquals('test_formula', $result->name);
|
||||
$this->assertEquals('W0 * H0', $result->expression);
|
||||
$this->assertEquals($this->model->id, $result->model_id);
|
||||
$this->assertEquals(1, $result->tenant_id);
|
||||
$this->assertEquals(1, $result->created_by);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_can_update_formula()
|
||||
{
|
||||
// Arrange
|
||||
$formula = ModelFormula::factory()
|
||||
->create(['model_id' => $this->model->id, 'name' => 'old_name']);
|
||||
|
||||
$updateData = [
|
||||
'name' => 'new_name',
|
||||
'expression' => 'W0 + H0',
|
||||
'description' => 'Updated description',
|
||||
];
|
||||
|
||||
// Act
|
||||
$result = $this->service->updateFormula($formula->id, $updateData);
|
||||
|
||||
// Assert
|
||||
$this->assertEquals('new_name', $result->name);
|
||||
$this->assertEquals('W0 + H0', $result->expression);
|
||||
$this->assertEquals('Updated description', $result->description);
|
||||
$this->assertEquals(1, $result->updated_by);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_can_delete_formula()
|
||||
{
|
||||
// Arrange
|
||||
$formula = ModelFormula::factory()
|
||||
->create(['model_id' => $this->model->id]);
|
||||
|
||||
// Act
|
||||
$result = $this->service->deleteFormula($formula->id);
|
||||
|
||||
// Assert
|
||||
$this->assertTrue($result);
|
||||
$this->assertSoftDeleted('model_formulas', ['id' => $formula->id]);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_evaluates_simple_arithmetic_expressions()
|
||||
{
|
||||
// Arrange
|
||||
$formula = ModelFormula::factory()
|
||||
->create([
|
||||
'model_id' => $this->model->id,
|
||||
'expression' => 'W0 * H0',
|
||||
'return_type' => 'NUMBER',
|
||||
]);
|
||||
|
||||
$parameters = ['W0' => 1000, 'H0' => 800];
|
||||
|
||||
// Act
|
||||
$result = $this->service->evaluateFormula($formula, $parameters);
|
||||
|
||||
// Assert
|
||||
$this->assertEquals(800000, $result);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_evaluates_complex_expressions_with_functions()
|
||||
{
|
||||
// Arrange
|
||||
$formula = ModelFormula::factory()
|
||||
->create([
|
||||
'model_id' => $this->model->id,
|
||||
'expression' => 'CEIL(W0 / 600)',
|
||||
'return_type' => 'NUMBER',
|
||||
]);
|
||||
|
||||
$parameters = ['W0' => 1400];
|
||||
|
||||
// Act
|
||||
$result = $this->service->evaluateFormula($formula, $parameters);
|
||||
|
||||
// Assert
|
||||
$this->assertEquals(3, $result); // ceil(1400/600) = ceil(2.33) = 3
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_evaluates_conditional_expressions()
|
||||
{
|
||||
// Arrange
|
||||
$formula = ModelFormula::factory()
|
||||
->create([
|
||||
'model_id' => $this->model->id,
|
||||
'expression' => 'IF(area <= 3, "SMALL", IF(area <= 6, "MEDIUM", "LARGE"))',
|
||||
'return_type' => 'STRING',
|
||||
]);
|
||||
|
||||
// Test cases
|
||||
$testCases = [
|
||||
['area' => 2, 'expected' => 'SMALL'],
|
||||
['area' => 5, 'expected' => 'MEDIUM'],
|
||||
['area' => 8, 'expected' => 'LARGE'],
|
||||
];
|
||||
|
||||
foreach ($testCases as $testCase) {
|
||||
// Act
|
||||
$result = $this->service->evaluateFormula($formula, $testCase);
|
||||
|
||||
// Assert
|
||||
$this->assertEquals($testCase['expected'], $result);
|
||||
}
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_handles_formula_dependencies()
|
||||
{
|
||||
// Arrange - Create formulas with dependencies
|
||||
$w1Formula = ModelFormula::factory()
|
||||
->create([
|
||||
'model_id' => $this->model->id,
|
||||
'name' => 'W1',
|
||||
'expression' => 'W0 + 120',
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
$h1Formula = ModelFormula::factory()
|
||||
->create([
|
||||
'model_id' => $this->model->id,
|
||||
'name' => 'H1',
|
||||
'expression' => 'H0 + 100',
|
||||
'sort_order' => 2,
|
||||
]);
|
||||
|
||||
$areaFormula = ModelFormula::factory()
|
||||
->create([
|
||||
'model_id' => $this->model->id,
|
||||
'name' => 'area',
|
||||
'expression' => 'W1 * H1 / 1000000',
|
||||
'sort_order' => 3,
|
||||
]);
|
||||
|
||||
$parameters = ['W0' => 1000, 'H0' => 800];
|
||||
|
||||
// Act
|
||||
$results = $this->service->evaluateAllFormulas($this->model->id, $parameters);
|
||||
|
||||
// Assert
|
||||
$this->assertEquals(1120, $results['W1']); // 1000 + 120
|
||||
$this->assertEquals(900, $results['H1']); // 800 + 100
|
||||
$this->assertEquals(1.008, $results['area']); // 1120 * 900 / 1000000
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_validates_formula_syntax()
|
||||
{
|
||||
// Test valid expressions
|
||||
$validExpressions = [
|
||||
'W0 + H0',
|
||||
'W0 * H0 / 1000',
|
||||
'CEIL(W0 / 600)',
|
||||
'IF(area > 5, "LARGE", "SMALL")',
|
||||
'SIN(angle * PI / 180)',
|
||||
];
|
||||
|
||||
foreach ($validExpressions as $expression) {
|
||||
$isValid = $this->service->validateExpressionSyntax($expression);
|
||||
$this->assertTrue($isValid, "Expression should be valid: {$expression}");
|
||||
}
|
||||
|
||||
// Test invalid expressions
|
||||
$invalidExpressions = [
|
||||
'W0 + + H0', // Double operator
|
||||
'W0 * )', // Unmatched parenthesis
|
||||
'UNKNOWN_FUNC(W0)', // Unknown function
|
||||
'', // Empty expression
|
||||
'W0 AND', // Incomplete expression
|
||||
];
|
||||
|
||||
foreach ($invalidExpressions as $expression) {
|
||||
$isValid = $this->service->validateExpressionSyntax($expression);
|
||||
$this->assertFalse($isValid, "Expression should be invalid: {$expression}");
|
||||
}
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_detects_circular_dependencies()
|
||||
{
|
||||
// Arrange - Create circular dependency
|
||||
ModelFormula::factory()
|
||||
->create([
|
||||
'model_id' => $this->model->id,
|
||||
'name' => 'A',
|
||||
'expression' => 'B + 10',
|
||||
]);
|
||||
|
||||
ModelFormula::factory()
|
||||
->create([
|
||||
'model_id' => $this->model->id,
|
||||
'name' => 'B',
|
||||
'expression' => 'C * 2',
|
||||
]);
|
||||
|
||||
ModelFormula::factory()
|
||||
->create([
|
||||
'model_id' => $this->model->id,
|
||||
'name' => 'C',
|
||||
'expression' => 'A / 3', // Circular dependency
|
||||
]);
|
||||
|
||||
// Act & Assert
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Circular dependency detected');
|
||||
|
||||
$this->service->evaluateAllFormulas($this->model->id, []);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_respects_tenant_isolation()
|
||||
{
|
||||
// Arrange
|
||||
$otherTenantModel = Model::factory()->create(['tenant_id' => 2]);
|
||||
ModelFormula::factory()
|
||||
->create(['model_id' => $otherTenantModel->id, 'tenant_id' => 2]);
|
||||
|
||||
// Act
|
||||
$result = $this->service->getFormulasByModel($otherTenantModel->id);
|
||||
|
||||
// Assert
|
||||
$this->assertCount(0, $result);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_handles_missing_parameters_gracefully()
|
||||
{
|
||||
// Arrange
|
||||
$formula = ModelFormula::factory()
|
||||
->create([
|
||||
'model_id' => $this->model->id,
|
||||
'expression' => 'W0 * H0',
|
||||
]);
|
||||
|
||||
$incompleteParameters = ['W0' => 1000]; // Missing H0
|
||||
|
||||
// Act & Assert
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Missing parameter: H0');
|
||||
|
||||
$this->service->evaluateFormula($formula, $incompleteParameters);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_can_bulk_update_formulas()
|
||||
{
|
||||
// Arrange
|
||||
$formulas = ModelFormula::factory()
|
||||
->count(3)
|
||||
->create(['model_id' => $this->model->id]);
|
||||
|
||||
$updateData = [
|
||||
[
|
||||
'id' => $formulas[0]->id,
|
||||
'name' => 'updated_formula_1',
|
||||
'expression' => 'W0 + 100',
|
||||
],
|
||||
[
|
||||
'id' => $formulas[1]->id,
|
||||
'name' => 'updated_formula_2',
|
||||
'expression' => 'H0 + 50',
|
||||
],
|
||||
[
|
||||
'id' => $formulas[2]->id,
|
||||
'is_active' => false,
|
||||
],
|
||||
];
|
||||
|
||||
// Act
|
||||
$result = $this->service->bulkUpdateFormulas($this->model->id, $updateData);
|
||||
|
||||
// Assert
|
||||
$this->assertTrue($result);
|
||||
|
||||
$updated = ModelFormula::whereIn('id', $formulas->pluck('id'))->get();
|
||||
$this->assertEquals('updated_formula_1', $updated->where('id', $formulas[0]->id)->first()->name);
|
||||
$this->assertEquals('W0 + 100', $updated->where('id', $formulas[0]->id)->first()->expression);
|
||||
$this->assertFalse($updated->where('id', $formulas[2]->id)->first()->is_active);
|
||||
}
|
||||
}
|
||||
261
tests/Unit/ModelParameterServiceTest.php
Normal file
261
tests/Unit/ModelParameterServiceTest.php
Normal file
@@ -0,0 +1,261 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Models\Model;
|
||||
use App\Models\ModelParameter;
|
||||
use App\Services\ModelParameterService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ModelParameterServiceTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private ModelParameterService $service;
|
||||
private Model $model;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->service = new ModelParameterService();
|
||||
$this->service->setTenantId(1)->setApiUserId(1);
|
||||
|
||||
$this->model = Model::factory()->screen()->create();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_can_get_all_parameters_for_model()
|
||||
{
|
||||
// Arrange
|
||||
ModelParameter::factory()
|
||||
->count(3)
|
||||
->create(['model_id' => $this->model->id]);
|
||||
|
||||
// Act
|
||||
$result = $this->service->getParametersByModel($this->model->id);
|
||||
|
||||
// Assert
|
||||
$this->assertCount(3, $result);
|
||||
$this->assertEquals($this->model->id, $result->first()->model_id);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_filters_inactive_parameters()
|
||||
{
|
||||
// Arrange
|
||||
ModelParameter::factory()
|
||||
->create(['model_id' => $this->model->id, 'is_active' => true]);
|
||||
ModelParameter::factory()
|
||||
->create(['model_id' => $this->model->id, 'is_active' => false]);
|
||||
|
||||
// Act
|
||||
$result = $this->service->getParametersByModel($this->model->id);
|
||||
|
||||
// Assert
|
||||
$this->assertCount(1, $result);
|
||||
$this->assertTrue($result->first()->is_active);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_orders_parameters_by_sort_order()
|
||||
{
|
||||
// Arrange
|
||||
$param1 = ModelParameter::factory()
|
||||
->create(['model_id' => $this->model->id, 'sort_order' => 3, 'name' => 'third']);
|
||||
$param2 = ModelParameter::factory()
|
||||
->create(['model_id' => $this->model->id, 'sort_order' => 1, 'name' => 'first']);
|
||||
$param3 = ModelParameter::factory()
|
||||
->create(['model_id' => $this->model->id, 'sort_order' => 2, 'name' => 'second']);
|
||||
|
||||
// Act
|
||||
$result = $this->service->getParametersByModel($this->model->id);
|
||||
|
||||
// Assert
|
||||
$this->assertEquals('first', $result->get(0)->name);
|
||||
$this->assertEquals('second', $result->get(1)->name);
|
||||
$this->assertEquals('third', $result->get(2)->name);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_can_create_parameter()
|
||||
{
|
||||
// Arrange
|
||||
$data = [
|
||||
'name' => 'test_param',
|
||||
'label' => 'Test Parameter',
|
||||
'type' => 'NUMBER',
|
||||
'default_value' => '100',
|
||||
'validation_rules' => ['required' => true, 'numeric' => true],
|
||||
'sort_order' => 1,
|
||||
'is_required' => true,
|
||||
];
|
||||
|
||||
// Act
|
||||
$result = $this->service->createParameter($this->model->id, $data);
|
||||
|
||||
// Assert
|
||||
$this->assertInstanceOf(ModelParameter::class, $result);
|
||||
$this->assertEquals('test_param', $result->name);
|
||||
$this->assertEquals($this->model->id, $result->model_id);
|
||||
$this->assertEquals(1, $result->tenant_id);
|
||||
$this->assertEquals(1, $result->created_by);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_can_update_parameter()
|
||||
{
|
||||
// Arrange
|
||||
$parameter = ModelParameter::factory()
|
||||
->create(['model_id' => $this->model->id, 'name' => 'old_name']);
|
||||
|
||||
$updateData = [
|
||||
'name' => 'new_name',
|
||||
'label' => 'New Label',
|
||||
'default_value' => '200',
|
||||
];
|
||||
|
||||
// Act
|
||||
$result = $this->service->updateParameter($parameter->id, $updateData);
|
||||
|
||||
// Assert
|
||||
$this->assertEquals('new_name', $result->name);
|
||||
$this->assertEquals('New Label', $result->label);
|
||||
$this->assertEquals('200', $result->default_value);
|
||||
$this->assertEquals(1, $result->updated_by);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_can_delete_parameter()
|
||||
{
|
||||
// Arrange
|
||||
$parameter = ModelParameter::factory()
|
||||
->create(['model_id' => $this->model->id]);
|
||||
|
||||
// Act
|
||||
$result = $this->service->deleteParameter($parameter->id);
|
||||
|
||||
// Assert
|
||||
$this->assertTrue($result);
|
||||
$this->assertSoftDeleted('model_parameters', ['id' => $parameter->id]);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_respects_tenant_isolation()
|
||||
{
|
||||
// Arrange
|
||||
$otherTenantModel = Model::factory()->create(['tenant_id' => 2]);
|
||||
$otherTenantParameter = ModelParameter::factory()
|
||||
->create(['model_id' => $otherTenantModel->id, 'tenant_id' => 2]);
|
||||
|
||||
// Act
|
||||
$result = $this->service->getParametersByModel($otherTenantModel->id);
|
||||
|
||||
// Assert
|
||||
$this->assertCount(0, $result);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_validates_parameter_types()
|
||||
{
|
||||
// Test NUMBER type
|
||||
$numberParam = ModelParameter::factory()
|
||||
->number()
|
||||
->create(['model_id' => $this->model->id]);
|
||||
|
||||
$this->assertEquals('NUMBER', $numberParam->type);
|
||||
$this->assertNull($numberParam->options);
|
||||
|
||||
// Test SELECT type
|
||||
$selectParam = ModelParameter::factory()
|
||||
->select()
|
||||
->create(['model_id' => $this->model->id]);
|
||||
|
||||
$this->assertEquals('SELECT', $selectParam->type);
|
||||
$this->assertNotNull($selectParam->options);
|
||||
|
||||
// Test BOOLEAN type
|
||||
$booleanParam = ModelParameter::factory()
|
||||
->boolean()
|
||||
->create(['model_id' => $this->model->id]);
|
||||
|
||||
$this->assertEquals('BOOLEAN', $booleanParam->type);
|
||||
$this->assertEquals('false', $booleanParam->default_value);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_can_bulk_update_parameters()
|
||||
{
|
||||
// Arrange
|
||||
$parameters = ModelParameter::factory()
|
||||
->count(3)
|
||||
->create(['model_id' => $this->model->id]);
|
||||
|
||||
$updateData = [
|
||||
[
|
||||
'id' => $parameters[0]->id,
|
||||
'name' => 'updated_param_1',
|
||||
'sort_order' => 10,
|
||||
],
|
||||
[
|
||||
'id' => $parameters[1]->id,
|
||||
'name' => 'updated_param_2',
|
||||
'sort_order' => 20,
|
||||
],
|
||||
[
|
||||
'id' => $parameters[2]->id,
|
||||
'is_active' => false,
|
||||
],
|
||||
];
|
||||
|
||||
// Act
|
||||
$result = $this->service->bulkUpdateParameters($this->model->id, $updateData);
|
||||
|
||||
// Assert
|
||||
$this->assertTrue($result);
|
||||
|
||||
$updated = ModelParameter::whereIn('id', $parameters->pluck('id'))->get();
|
||||
$this->assertEquals('updated_param_1', $updated->where('id', $parameters[0]->id)->first()->name);
|
||||
$this->assertEquals('updated_param_2', $updated->where('id', $parameters[1]->id)->first()->name);
|
||||
$this->assertFalse($updated->where('id', $parameters[2]->id)->first()->is_active);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_validates_required_fields()
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
|
||||
$this->service->createParameter($this->model->id, [
|
||||
'label' => 'Missing Name Parameter',
|
||||
'type' => 'NUMBER',
|
||||
]);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_handles_parameter_validation_rules()
|
||||
{
|
||||
// Arrange
|
||||
$parameter = ModelParameter::factory()
|
||||
->create([
|
||||
'model_id' => $this->model->id,
|
||||
'type' => 'NUMBER',
|
||||
'validation_rules' => json_encode([
|
||||
'required' => true,
|
||||
'numeric' => true,
|
||||
'min' => 100,
|
||||
'max' => 1000,
|
||||
]),
|
||||
]);
|
||||
|
||||
// Act
|
||||
$validationRules = json_decode($parameter->validation_rules, true);
|
||||
|
||||
// Assert
|
||||
$this->assertArrayHasKey('required', $validationRules);
|
||||
$this->assertArrayHasKey('numeric', $validationRules);
|
||||
$this->assertArrayHasKey('min', $validationRules);
|
||||
$this->assertArrayHasKey('max', $validationRules);
|
||||
$this->assertEquals(100, $validationRules['min']);
|
||||
$this->assertEquals(1000, $validationRules['max']);
|
||||
}
|
||||
}
|
||||
405
tests/Unit/ProductFromModelServiceTest.php
Normal file
405
tests/Unit/ProductFromModelServiceTest.php
Normal file
@@ -0,0 +1,405 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Models\BomConditionRule;
|
||||
use App\Models\Model;
|
||||
use App\Models\ModelFormula;
|
||||
use App\Models\ModelParameter;
|
||||
use App\Services\ProductFromModelService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ProductFromModelServiceTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private ProductFromModelService $service;
|
||||
private Model $model;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->service = new ProductFromModelService();
|
||||
$this->service->setTenantId(1)->setApiUserId(1);
|
||||
|
||||
$this->model = Model::factory()->screen()->create(['code' => 'KSS01']);
|
||||
$this->setupKSS01Model();
|
||||
}
|
||||
|
||||
private function setupKSS01Model(): void
|
||||
{
|
||||
// Create parameters
|
||||
ModelParameter::factory()
|
||||
->screenParameters()
|
||||
->create(['model_id' => $this->model->id]);
|
||||
|
||||
// Create formulas
|
||||
ModelFormula::factory()
|
||||
->screenFormulas()
|
||||
->create(['model_id' => $this->model->id]);
|
||||
|
||||
// Create condition rules
|
||||
BomConditionRule::factory()
|
||||
->screenRules()
|
||||
->create(['model_id' => $this->model->id]);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_can_resolve_bom_for_small_screen()
|
||||
{
|
||||
// Arrange
|
||||
$inputParams = [
|
||||
'W0' => 1000,
|
||||
'H0' => 800,
|
||||
'screen_type' => 'SCREEN',
|
||||
'install_type' => 'WALL',
|
||||
'power_source' => 'AC',
|
||||
];
|
||||
|
||||
// Act
|
||||
$result = $this->service->resolveBom($this->model->id, $inputParams);
|
||||
|
||||
// Assert
|
||||
$this->assertArrayHasKey('parameters', $result);
|
||||
$this->assertArrayHasKey('formulas', $result);
|
||||
$this->assertArrayHasKey('bom_items', $result);
|
||||
|
||||
// Check calculated formulas
|
||||
$formulas = $result['formulas'];
|
||||
$this->assertEquals(1120, $formulas['W1']); // W0 + 120
|
||||
$this->assertEquals(900, $formulas['H1']); // H0 + 100
|
||||
$this->assertEquals(1.008, $formulas['area']); // W1 * H1 / 1000000
|
||||
|
||||
// Check BOM items
|
||||
$bomItems = $result['bom_items'];
|
||||
$this->assertGreaterThan(0, count($bomItems));
|
||||
|
||||
// Check specific components
|
||||
$caseItem = collect($bomItems)->first(fn($item) => str_contains($item['component_code'], 'CASE'));
|
||||
$this->assertNotNull($caseItem);
|
||||
$this->assertEquals('CASE-SMALL', $caseItem['component_code']); // area <= 3
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_can_resolve_bom_for_large_screen()
|
||||
{
|
||||
// Arrange
|
||||
$inputParams = [
|
||||
'W0' => 2500,
|
||||
'H0' => 1500,
|
||||
'screen_type' => 'SCREEN',
|
||||
'install_type' => 'SIDE',
|
||||
'power_source' => 'AC',
|
||||
];
|
||||
|
||||
// Act
|
||||
$result = $this->service->resolveBom($this->model->id, $inputParams);
|
||||
|
||||
// Assert
|
||||
$formulas = $result['formulas'];
|
||||
$this->assertEquals(2620, $formulas['W1']); // 2500 + 120
|
||||
$this->assertEquals(1600, $formulas['H1']); // 1500 + 100
|
||||
$this->assertEquals(4.192, $formulas['area']); // 2620 * 1600 / 1000000
|
||||
|
||||
// Check that large case is selected
|
||||
$bomItems = $result['bom_items'];
|
||||
$caseItem = collect($bomItems)->first(fn($item) => str_contains($item['component_code'], 'CASE'));
|
||||
$this->assertEquals('CASE-MEDIUM', $caseItem['component_code']); // 3 < area <= 6
|
||||
|
||||
// Check bracket quantity calculation
|
||||
$bracketItem = collect($bomItems)->first(fn($item) => $item['component_code'] === 'BOTTOM-001');
|
||||
$this->assertNotNull($bracketItem);
|
||||
$this->assertEquals(3, $bracketItem['quantity']); // CEIL(2620 / 1000)
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_can_resolve_bom_for_maximum_size()
|
||||
{
|
||||
// Arrange
|
||||
$inputParams = [
|
||||
'W0' => 3000,
|
||||
'H0' => 2000,
|
||||
'screen_type' => 'SCREEN',
|
||||
'install_type' => 'MIXED',
|
||||
'power_source' => 'DC',
|
||||
];
|
||||
|
||||
// Act
|
||||
$result = $this->service->resolveBom($this->model->id, $inputParams);
|
||||
|
||||
// Assert
|
||||
$formulas = $result['formulas'];
|
||||
$this->assertEquals(3120, $formulas['W1']);
|
||||
$this->assertEquals(2100, $formulas['H1']);
|
||||
$this->assertEquals(6.552, $formulas['area']); // > 6
|
||||
|
||||
// Check that large case is selected
|
||||
$bomItems = $result['bom_items'];
|
||||
$caseItem = collect($bomItems)->first(fn($item) => str_contains($item['component_code'], 'CASE'));
|
||||
$this->assertEquals('CASE-LARGE', $caseItem['component_code']); // area > 6
|
||||
|
||||
// Check motor capacity
|
||||
$this->assertEquals('2HP', $formulas['motor']); // area > 6
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_handles_slat_type_differences()
|
||||
{
|
||||
// Arrange
|
||||
$inputParams = [
|
||||
'W0' => 1500,
|
||||
'H0' => 1000,
|
||||
'screen_type' => 'SLAT',
|
||||
'install_type' => 'WALL',
|
||||
'power_source' => 'AC',
|
||||
];
|
||||
|
||||
// Act
|
||||
$result = $this->service->resolveBom($this->model->id, $inputParams);
|
||||
|
||||
// Assert
|
||||
$bomItems = $result['bom_items'];
|
||||
|
||||
// Check that SLAT pipe is used instead of SCREEN pipe
|
||||
$screenPipe = collect($bomItems)->first(fn($item) => $item['component_code'] === 'PIPE-SCREEN');
|
||||
$slatPipe = collect($bomItems)->first(fn($item) => $item['component_code'] === 'PIPE-SLAT');
|
||||
|
||||
$this->assertNull($screenPipe);
|
||||
$this->assertNotNull($slatPipe);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_validates_input_parameters()
|
||||
{
|
||||
// Test missing required parameter
|
||||
$incompleteParams = [
|
||||
'W0' => 1000,
|
||||
// Missing H0, screen_type, etc.
|
||||
];
|
||||
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Missing required parameter');
|
||||
|
||||
$this->service->resolveBom($this->model->id, $incompleteParams);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_validates_parameter_ranges()
|
||||
{
|
||||
// Test out-of-range parameter
|
||||
$invalidParams = [
|
||||
'W0' => 100, // Below minimum (500)
|
||||
'H0' => 800,
|
||||
'screen_type' => 'SCREEN',
|
||||
'install_type' => 'WALL',
|
||||
'power_source' => 'AC',
|
||||
];
|
||||
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Parameter W0 value 100 is outside valid range');
|
||||
|
||||
$this->service->resolveBom($this->model->id, $invalidParams);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_validates_select_parameter_options()
|
||||
{
|
||||
// Test invalid select option
|
||||
$invalidParams = [
|
||||
'W0' => 1000,
|
||||
'H0' => 800,
|
||||
'screen_type' => 'INVALID_TYPE',
|
||||
'install_type' => 'WALL',
|
||||
'power_source' => 'AC',
|
||||
];
|
||||
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Invalid value for parameter screen_type');
|
||||
|
||||
$this->service->resolveBom($this->model->id, $invalidParams);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_can_preview_product_before_creation()
|
||||
{
|
||||
// Arrange
|
||||
$inputParams = [
|
||||
'W0' => 1200,
|
||||
'H0' => 900,
|
||||
'screen_type' => 'SCREEN',
|
||||
'install_type' => 'WALL',
|
||||
'power_source' => 'AC',
|
||||
];
|
||||
|
||||
// Act
|
||||
$preview = $this->service->previewProduct($this->model->id, $inputParams);
|
||||
|
||||
// Assert
|
||||
$this->assertArrayHasKey('product_info', $preview);
|
||||
$this->assertArrayHasKey('bom_summary', $preview);
|
||||
$this->assertArrayHasKey('estimated_cost', $preview);
|
||||
|
||||
$productInfo = $preview['product_info'];
|
||||
$this->assertStringContains('KSS01', $productInfo['suggested_code']);
|
||||
$this->assertStringContains('1200x900', $productInfo['suggested_name']);
|
||||
|
||||
$bomSummary = $preview['bom_summary'];
|
||||
$this->assertArrayHasKey('total_components', $bomSummary);
|
||||
$this->assertArrayHasKey('component_categories', $bomSummary);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_can_create_product_from_resolved_bom()
|
||||
{
|
||||
// Arrange
|
||||
$inputParams = [
|
||||
'W0' => 1000,
|
||||
'H0' => 800,
|
||||
'screen_type' => 'SCREEN',
|
||||
'install_type' => 'WALL',
|
||||
'power_source' => 'AC',
|
||||
];
|
||||
|
||||
$productData = [
|
||||
'code' => 'KSS01-001',
|
||||
'name' => '스크린 블라인드 1000x800',
|
||||
'description' => '매개변수 기반 생성 제품',
|
||||
];
|
||||
|
||||
// Act
|
||||
$result = $this->service->createProductFromModel($this->model->id, $inputParams, $productData);
|
||||
|
||||
// Assert
|
||||
$this->assertArrayHasKey('product', $result);
|
||||
$this->assertArrayHasKey('bom_items', $result);
|
||||
|
||||
$product = $result['product'];
|
||||
$this->assertEquals('KSS01-001', $product['code']);
|
||||
$this->assertEquals('스크린 블라인드 1000x800', $product['name']);
|
||||
|
||||
$bomItems = $result['bom_items'];
|
||||
$this->assertGreaterThan(0, count($bomItems));
|
||||
|
||||
// Verify BOM items are properly linked to the product
|
||||
foreach ($bomItems as $item) {
|
||||
$this->assertEquals($product['id'], $item['product_id']);
|
||||
$this->assertNotEmpty($item['component_code']);
|
||||
$this->assertGreaterThan(0, $item['quantity']);
|
||||
}
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_handles_formula_evaluation_errors_gracefully()
|
||||
{
|
||||
// Arrange - Create a formula with invalid expression
|
||||
ModelFormula::factory()
|
||||
->create([
|
||||
'model_id' => $this->model->id,
|
||||
'name' => 'invalid_formula',
|
||||
'expression' => 'UNKNOWN_FUNCTION(W0)',
|
||||
'sort_order' => 999,
|
||||
]);
|
||||
|
||||
$inputParams = [
|
||||
'W0' => 1000,
|
||||
'H0' => 800,
|
||||
'screen_type' => 'SCREEN',
|
||||
'install_type' => 'WALL',
|
||||
'power_source' => 'AC',
|
||||
];
|
||||
|
||||
// Act & Assert
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage('Formula evaluation failed');
|
||||
|
||||
$this->service->resolveBom($this->model->id, $inputParams);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_respects_tenant_isolation()
|
||||
{
|
||||
// Arrange
|
||||
$otherTenantModel = Model::factory()->create(['tenant_id' => 2]);
|
||||
|
||||
$inputParams = [
|
||||
'W0' => 1000,
|
||||
'H0' => 800,
|
||||
'screen_type' => 'SCREEN',
|
||||
'install_type' => 'WALL',
|
||||
'power_source' => 'AC',
|
||||
];
|
||||
|
||||
// Act & Assert
|
||||
$this->expectException(\ModelNotFoundException::class);
|
||||
|
||||
$this->service->resolveBom($otherTenantModel->id, $inputParams);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_caches_formula_results_for_performance()
|
||||
{
|
||||
// Arrange
|
||||
$inputParams = [
|
||||
'W0' => 1000,
|
||||
'H0' => 800,
|
||||
'screen_type' => 'SCREEN',
|
||||
'install_type' => 'WALL',
|
||||
'power_source' => 'AC',
|
||||
];
|
||||
|
||||
// Act - First call
|
||||
$start1 = microtime(true);
|
||||
$result1 = $this->service->resolveBom($this->model->id, $inputParams);
|
||||
$time1 = microtime(true) - $start1;
|
||||
|
||||
// Act - Second call with same parameters
|
||||
$start2 = microtime(true);
|
||||
$result2 = $this->service->resolveBom($this->model->id, $inputParams);
|
||||
$time2 = microtime(true) - $start2;
|
||||
|
||||
// Assert
|
||||
$this->assertEquals($result1['formulas'], $result2['formulas']);
|
||||
$this->assertEquals($result1['bom_items'], $result2['bom_items']);
|
||||
|
||||
// Second call should be faster due to caching
|
||||
$this->assertLessThan($time1, $time2 * 2); // Allow some variance
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_handles_boundary_conditions_correctly()
|
||||
{
|
||||
// Test exactly at boundary values
|
||||
$boundaryTestCases = [
|
||||
// Test area exactly at 3 (boundary between small and medium case)
|
||||
[
|
||||
'W0' => 1612, // Will result in W1=1732, need H1=1732 for area=3
|
||||
'H0' => 1632, // Will result in H1=1732, area = 1732*1732/1000000 ≈ 3
|
||||
'expected_case' => 'CASE-SMALL', // area <= 3
|
||||
],
|
||||
// Test area exactly at 6 (boundary between medium and large case)
|
||||
[
|
||||
'W0' => 2329, // Will result in area slightly above 6
|
||||
'H0' => 2349,
|
||||
'expected_case' => 'CASE-LARGE', // area > 6
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($boundaryTestCases as $testCase) {
|
||||
$inputParams = [
|
||||
'W0' => $testCase['W0'],
|
||||
'H0' => $testCase['H0'],
|
||||
'screen_type' => 'SCREEN',
|
||||
'install_type' => 'WALL',
|
||||
'power_source' => 'AC',
|
||||
];
|
||||
|
||||
$result = $this->service->resolveBom($this->model->id, $inputParams);
|
||||
$bomItems = $result['bom_items'];
|
||||
$caseItem = collect($bomItems)->first(fn($item) => str_contains($item['component_code'], 'CASE'));
|
||||
|
||||
$this->assertEquals($testCase['expected_case'], $caseItem['component_code'],
|
||||
"Failed boundary test for W0={$testCase['W0']}, H0={$testCase['H0']}, area={$result['formulas']['area']}");
|
||||
}
|
||||
}
|
||||
}
|
||||
1121
tests/postman/parametric_bom.postman_collection.json
Normal file
1121
tests/postman/parametric_bom.postman_collection.json
Normal file
File diff suppressed because it is too large
Load Diff
49
tests/postman/parametric_bom.postman_environment.json
Normal file
49
tests/postman/parametric_bom.postman_environment.json
Normal file
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"id": "parametric-bom-test-env",
|
||||
"name": "Parametric BOM Test Environment",
|
||||
"values": [
|
||||
{
|
||||
"key": "base_url",
|
||||
"value": "http://localhost:8000/api/v1",
|
||||
"description": "Base API URL for the SAM application",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"key": "api_key",
|
||||
"value": "your-api-key-here",
|
||||
"description": "API key for authentication (update with actual key)",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"key": "user_email",
|
||||
"value": "demo@kss01.com",
|
||||
"description": "Test user email (from KSS01ModelSeeder)",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"key": "user_password",
|
||||
"value": "kss01demo",
|
||||
"description": "Test user password (from KSS01ModelSeeder)",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"key": "auth_token",
|
||||
"value": "",
|
||||
"description": "Bearer token obtained from login (auto-populated)",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"key": "tenant_id",
|
||||
"value": "",
|
||||
"description": "Current tenant ID (auto-populated)",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"key": "model_id",
|
||||
"value": "",
|
||||
"description": "KSS01 model ID (auto-populated from collection)",
|
||||
"enabled": true
|
||||
}
|
||||
],
|
||||
"_postman_variable_scope": "environment"
|
||||
}
|
||||
Reference in New Issue
Block a user