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:
2025-09-30 23:31:14 +09:00
parent d94ab59fd1
commit bf8036a64b
81 changed files with 22632 additions and 102 deletions

View 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'));
}
}

View 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']);
}
}

View 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
]);
}
}

View 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
]);
}
}

View 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');
}
}

View 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']);
}
}
}

View 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
View 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

View 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}"
);
}
}
}

View 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);
}
}

View 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);
}
}

View 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']);
}
}

View 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']}");
}
}
}

File diff suppressed because it is too large Load Diff

View 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"
}