[REFACTOR] 統一訂單同步 API 錯誤回應與修正 Linter 警告
This commit is contained in:
211
tests/Feature/Integration/InventoryQueryApiTest.php
Normal file
211
tests/Feature/Integration/InventoryQueryApiTest.php
Normal file
@@ -0,0 +1,211 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Integration;
|
||||
|
||||
use Tests\TestCase;
|
||||
use App\Modules\Core\Models\Tenant;
|
||||
use App\Modules\Core\Models\User;
|
||||
use App\Modules\Inventory\Models\Product;
|
||||
use App\Modules\Inventory\Models\Warehouse;
|
||||
use App\Modules\Inventory\Models\Inventory;
|
||||
use App\Modules\Inventory\Models\Category;
|
||||
use App\Modules\Inventory\Models\Unit;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
|
||||
class InventoryQueryApiTest extends TestCase
|
||||
{
|
||||
protected $tenant;
|
||||
protected $user;
|
||||
protected $domain;
|
||||
protected $warehouse;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Artisan::call('migrate:fresh');
|
||||
|
||||
$this->domain = 'inventory-test-' . Str::random(8) . '.erp.local';
|
||||
$tenantId = 'test_tenant_inv_' . Str::random(8);
|
||||
|
||||
tenancy()->central(function () use ($tenantId) {
|
||||
$this->tenant = Tenant::create([
|
||||
'id' => $tenantId,
|
||||
'name' => 'Inventory Test Tenant',
|
||||
]);
|
||||
$this->tenant->domains()->create(['domain' => $this->domain]);
|
||||
});
|
||||
|
||||
tenancy()->initialize($this->tenant);
|
||||
Artisan::call('tenants:migrate');
|
||||
|
||||
$this->user = User::factory()->create(['name' => 'Inventory Admin']);
|
||||
|
||||
// 建立測試資料
|
||||
$cat = Category::firstOrCreate(['name' => '飲品'], ['code' => 'CAT-DRINK']);
|
||||
$unit = Unit::firstOrCreate(['name' => '瓶'], ['code' => 'BO']);
|
||||
|
||||
$p1 = Product::create([
|
||||
'name' => '可口可樂',
|
||||
'code' => 'COKE-001',
|
||||
'barcode' => '4710001',
|
||||
'external_pos_id' => 'POS-COKE',
|
||||
'price' => 25,
|
||||
'category_id' => $cat->id,
|
||||
'base_unit_id' => $unit->id,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$p2 = Product::create([
|
||||
'name' => '百事可樂',
|
||||
'code' => 'PEPSI-001',
|
||||
'barcode' => '4710002',
|
||||
'external_pos_id' => 'POS-PEPSI',
|
||||
'price' => 23,
|
||||
'category_id' => $cat->id,
|
||||
'base_unit_id' => $unit->id,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->warehouse = Warehouse::create([
|
||||
'name' => '台北門市倉',
|
||||
'code' => 'WH-TP-01',
|
||||
'type' => 'retail',
|
||||
]);
|
||||
|
||||
// 建立庫存
|
||||
Inventory::create([
|
||||
'warehouse_id' => $this->warehouse->id,
|
||||
'product_id' => $p1->id,
|
||||
'quantity' => 100,
|
||||
'unit_cost' => 15,
|
||||
'total_value' => 1500,
|
||||
'batch_number' => 'BATCH-001',
|
||||
'arrival_date' => now(),
|
||||
]);
|
||||
|
||||
Inventory::create([
|
||||
'warehouse_id' => $this->warehouse->id,
|
||||
'product_id' => $p2->id,
|
||||
'quantity' => 50,
|
||||
'unit_cost' => 12,
|
||||
'total_value' => 600,
|
||||
'batch_number' => 'BATCH-002',
|
||||
'arrival_date' => now(),
|
||||
]);
|
||||
|
||||
tenancy()->end();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if ($this->tenant) {
|
||||
$this->tenant->delete();
|
||||
}
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function test_can_query_all_inventory_for_warehouse()
|
||||
{
|
||||
Sanctum::actingAs($this->user, ['*']);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'X-Tenant-Domain' => $this->domain,
|
||||
'Accept' => 'application/json',
|
||||
])->getJson("/api/v1/integration/inventory/{$this->warehouse->code}");
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonPath('status', 'success')
|
||||
->assertJsonCount(2, 'data');
|
||||
}
|
||||
|
||||
public function test_can_filter_inventory_by_product_id()
|
||||
{
|
||||
Sanctum::actingAs($this->user, ['*']);
|
||||
|
||||
// 先找出可樂的 ERP ID
|
||||
$productId = Product::where('code', 'COKE-001')->value('id');
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'X-Tenant-Domain' => $this->domain,
|
||||
'Accept' => 'application/json',
|
||||
])->getJson("/api/v1/integration/inventory/{$this->warehouse->code}?product_id={$productId}");
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonCount(1, 'data')
|
||||
->assertJsonPath('data.0.product_code', 'COKE-001')
|
||||
->assertJsonPath('data.0.quantity', 100);
|
||||
}
|
||||
|
||||
public function test_can_filter_inventory_by_external_pos_id()
|
||||
{
|
||||
Sanctum::actingAs($this->user, ['*']);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'X-Tenant-Domain' => $this->domain,
|
||||
'Accept' => 'application/json',
|
||||
])->getJson("/api/v1/integration/inventory/{$this->warehouse->code}?external_pos_id=POS-COKE");
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonCount(1, 'data')
|
||||
->assertJsonPath('data.0.external_pos_id', 'POS-COKE')
|
||||
->assertJsonPath('data.0.quantity', 100);
|
||||
}
|
||||
|
||||
public function test_can_filter_inventory_by_barcode()
|
||||
{
|
||||
Sanctum::actingAs($this->user, ['*']);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'X-Tenant-Domain' => $this->domain,
|
||||
'Accept' => 'application/json',
|
||||
])->getJson("/api/v1/integration/inventory/{$this->warehouse->code}?barcode=4710002");
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonCount(1, 'data')
|
||||
->assertJsonPath('data.0.product_code', 'PEPSI-001')
|
||||
->assertJsonPath('data.0.quantity', 50);
|
||||
}
|
||||
|
||||
public function test_can_filter_inventory_by_code()
|
||||
{
|
||||
Sanctum::actingAs($this->user, ['*']);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'X-Tenant-Domain' => $this->domain,
|
||||
'Accept' => 'application/json',
|
||||
])->getJson("/api/v1/integration/inventory/{$this->warehouse->code}?code=COKE-001");
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonCount(1, 'data')
|
||||
->assertJsonPath('data.0.product_code', 'COKE-001');
|
||||
}
|
||||
|
||||
public function test_returns_empty_when_no_match()
|
||||
{
|
||||
Sanctum::actingAs($this->user, ['*']);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'X-Tenant-Domain' => $this->domain,
|
||||
'Accept' => 'application/json',
|
||||
])->getJson("/api/v1/integration/inventory/{$this->warehouse->code}?product_id=NON-EXISTENT");
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonCount(0, 'data');
|
||||
}
|
||||
|
||||
public function test_returns_404_when_warehouse_not_found()
|
||||
{
|
||||
Sanctum::actingAs($this->user, ['*']);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'X-Tenant-Domain' => $this->domain,
|
||||
'Accept' => 'application/json',
|
||||
])->getJson("/api/v1/integration/inventory/INVALID-WH");
|
||||
|
||||
$response->assertStatus(404);
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,10 @@ use App\Modules\Core\Models\Tenant;
|
||||
use App\Modules\Core\Models\User;
|
||||
use App\Modules\Inventory\Models\Product;
|
||||
use App\Modules\Integration\Models\SalesOrder;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
use Stancl\Tenancy\Facades\Tenancy;
|
||||
|
||||
class PosApiTest extends TestCase
|
||||
@@ -26,9 +29,9 @@ class PosApiTest extends TestCase
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
\Artisan::call('migrate:fresh'); // Force fresh DB for isolation without RefreshDatabase trait
|
||||
Artisan::call('migrate:fresh'); // Force fresh DB for isolation without RefreshDatabase trait
|
||||
|
||||
$this->domain = 'test-' . \Illuminate\Support\Str::random(8) . '.erp.local';
|
||||
$this->domain = 'test-' . Str::random(8) . '.erp.local';
|
||||
$tenantId = 'test_tenant_' . \Illuminate\Support\Str::random(8);
|
||||
|
||||
// Ensure we are in central context
|
||||
@@ -45,7 +48,7 @@ class PosApiTest extends TestCase
|
||||
// Initialize to create User and Token
|
||||
tenancy()->initialize($this->tenant);
|
||||
|
||||
\Artisan::call('tenants:migrate');
|
||||
Artisan::call('tenants:migrate');
|
||||
|
||||
$this->user = User::factory()->create([
|
||||
'email' => 'admin@test.local',
|
||||
@@ -83,26 +86,33 @@ class PosApiTest extends TestCase
|
||||
{
|
||||
\Laravel\Sanctum\Sanctum::actingAs($this->user, ['*']);
|
||||
|
||||
$payload = [
|
||||
'external_pos_id' => 'EXT-NEW-002',
|
||||
'name' => 'New Product',
|
||||
'price' => 200,
|
||||
'sku' => 'SKU-NEW',
|
||||
$productData = [
|
||||
'external_pos_id' => 'POS-P-999',
|
||||
'name' => '新款可口可樂',
|
||||
'price' => 29.0,
|
||||
'barcode' => '471000999',
|
||||
'category' => '飲品',
|
||||
'unit' => '瓶',
|
||||
];
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'X-Tenant-Domain' => $this->domain,
|
||||
'Accept' => 'application/json',
|
||||
])->postJson('/api/v1/integration/products/upsert', $payload);
|
||||
])->postJson('/api/v1/integration/products/upsert', $productData);
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonPath('message', 'Product synced successfully');
|
||||
->assertJsonPath('data.external_pos_id', 'POS-P-999')
|
||||
->assertJsonStructure([
|
||||
'data' => [
|
||||
'id', 'external_pos_id', 'code', 'barcode'
|
||||
]
|
||||
]);
|
||||
|
||||
// Verify in Tenant DB
|
||||
tenancy()->initialize($this->tenant);
|
||||
$this->assertDatabaseHas('products', [
|
||||
'external_pos_id' => 'EXT-NEW-002',
|
||||
'name' => 'New Product',
|
||||
'external_pos_id' => 'POS-P-999',
|
||||
'name' => '新款可口可樂',
|
||||
]);
|
||||
tenancy()->end();
|
||||
}
|
||||
@@ -115,6 +125,8 @@ class PosApiTest extends TestCase
|
||||
'external_pos_id' => 'EXT-001',
|
||||
'name' => 'Updated Name',
|
||||
'price' => 150,
|
||||
'category' => '飲品',
|
||||
'unit' => '瓶',
|
||||
];
|
||||
|
||||
$response = $this->withHeaders([
|
||||
@@ -147,7 +159,7 @@ class PosApiTest extends TestCase
|
||||
'product_id' => $product->id,
|
||||
'warehouse_id' => $warehouse->id,
|
||||
'quantity' => 100,
|
||||
'batch_number' => 'BATCH-TEST-001',
|
||||
'batch_number' => 'NO-BATCH', // 改為系統預設值
|
||||
'arrival_date' => now()->toDateString(),
|
||||
'origin_country' => 'TW',
|
||||
]);
|
||||
@@ -163,7 +175,7 @@ class PosApiTest extends TestCase
|
||||
'sold_at' => now()->toIso8601String(),
|
||||
'items' => [
|
||||
[
|
||||
'pos_product_id' => 'EXT-001',
|
||||
'product_id' => $product->id,
|
||||
'qty' => 5,
|
||||
'price' => 100
|
||||
]
|
||||
@@ -208,6 +220,245 @@ class PosApiTest extends TestCase
|
||||
'type' => '出庫',
|
||||
]);
|
||||
|
||||
}
|
||||
|
||||
public function test_order_creation_with_batch_number_deduction()
|
||||
{
|
||||
tenancy()->initialize($this->tenant);
|
||||
$product = Product::where('code', 'P-001')->first();
|
||||
$warehouse = \App\Modules\Inventory\Models\Warehouse::firstOrCreate(['code' => 'MAIN'], ['name' => 'Main Warehouse']);
|
||||
|
||||
// Scenario: Two records for the same product, one with batch, one without
|
||||
\App\Modules\Inventory\Models\Inventory::create([
|
||||
'product_id' => $product->id,
|
||||
'warehouse_id' => $warehouse->id,
|
||||
'quantity' => 10,
|
||||
'batch_number' => 'BATCH-A',
|
||||
'arrival_date' => now()->toDateString(),
|
||||
]);
|
||||
|
||||
\App\Modules\Inventory\Models\Inventory::create([
|
||||
'product_id' => $product->id,
|
||||
'warehouse_id' => $warehouse->id,
|
||||
'quantity' => 5,
|
||||
'batch_number' => 'NO-BATCH',
|
||||
'arrival_date' => now()->toDateString(),
|
||||
]);
|
||||
|
||||
tenancy()->end();
|
||||
|
||||
\Laravel\Sanctum\Sanctum::actingAs($this->user, ['*']);
|
||||
|
||||
// 1. Test deducting from SPECIFIC batch
|
||||
$payloadA = [
|
||||
'external_order_id' => 'ORD-BATCH-A',
|
||||
'warehouse_code' => 'MAIN',
|
||||
'items' => [
|
||||
[
|
||||
'product_id' => $product->id,
|
||||
'batch_number' => 'BATCH-A',
|
||||
'qty' => 3,
|
||||
'price' => 100
|
||||
]
|
||||
]
|
||||
];
|
||||
|
||||
$this->withHeaders(['X-Tenant-Domain' => $this->domain])
|
||||
->postJson('/api/v1/integration/orders', $payloadA)
|
||||
->assertStatus(201);
|
||||
|
||||
// 2. Test deducting from NULL batch (default)
|
||||
$payloadNull = [
|
||||
'external_order_id' => 'ORD-BATCH-NULL',
|
||||
'warehouse_code' => 'MAIN',
|
||||
'items' => [
|
||||
[
|
||||
'product_id' => $product->id,
|
||||
'batch_number' => null, // 測試不傳入批號時應自動轉向 NO-BATCH
|
||||
'qty' => 2,
|
||||
'price' => 100
|
||||
]
|
||||
]
|
||||
];
|
||||
|
||||
$this->withHeaders(['X-Tenant-Domain' => $this->domain])
|
||||
->postJson('/api/v1/integration/orders', $payloadNull)
|
||||
->assertStatus(201);
|
||||
|
||||
tenancy()->initialize($this->tenant);
|
||||
// Verify BATCH-A: 10 - 3 = 7
|
||||
$this->assertDatabaseHas('inventories', [
|
||||
'product_id' => $product->id,
|
||||
'batch_number' => 'BATCH-A',
|
||||
'quantity' => 7,
|
||||
]);
|
||||
|
||||
// Verify NO-BATCH batch: 5 - 2 = 3
|
||||
$this->assertDatabaseHas('inventories', [
|
||||
'product_id' => $product->id,
|
||||
'batch_number' => 'NO-BATCH',
|
||||
'quantity' => 3,
|
||||
]);
|
||||
tenancy()->end();
|
||||
}
|
||||
|
||||
public function test_upsert_auto_generates_code_and_barcode_if_missing()
|
||||
{
|
||||
\Laravel\Sanctum\Sanctum::actingAs($this->user, ['*']);
|
||||
|
||||
$productData = [
|
||||
'external_pos_id' => 'POS-AUTO-GEN-01',
|
||||
'name' => '自動編號商品',
|
||||
'price' => 50.0,
|
||||
'category' => '測試分類',
|
||||
'unit' => '個',
|
||||
];
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'X-Tenant-Domain' => $this->domain,
|
||||
'Accept' => 'application/json',
|
||||
])->postJson('/api/v1/integration/products/upsert', $productData);
|
||||
|
||||
$response->assertStatus(200);
|
||||
|
||||
$data = $response->json('data');
|
||||
$this->assertNotEmpty($data['code']);
|
||||
$this->assertNotEmpty($data['barcode']);
|
||||
$this->assertEquals(8, strlen($data['code']));
|
||||
$this->assertEquals(13, strlen($data['barcode']));
|
||||
|
||||
// Ensure they are stored in DB
|
||||
tenancy()->initialize($this->tenant);
|
||||
$this->assertDatabaseHas('products', [
|
||||
'external_pos_id' => 'POS-AUTO-GEN-01',
|
||||
'code' => $data['code'],
|
||||
'barcode' => $data['barcode'],
|
||||
]);
|
||||
tenancy()->end();
|
||||
}
|
||||
|
||||
public function test_inventory_query_returns_detailed_info()
|
||||
{
|
||||
tenancy()->initialize($this->tenant);
|
||||
|
||||
// 1. 建立具有完整資訊的商品
|
||||
$category = \App\Modules\Inventory\Models\Category::create(['name' => '測試大類']);
|
||||
$unit = \App\Modules\Inventory\Models\Unit::create(['name' => '測試單位']);
|
||||
|
||||
$product = Product::create([
|
||||
'external_pos_id' => 'DETAIL-001',
|
||||
'code' => 'DET-001',
|
||||
'barcode' => '1234567890123',
|
||||
'name' => '詳盡商品',
|
||||
'category_id' => $category->id,
|
||||
'base_unit_id' => $unit->id,
|
||||
'price' => 123.45,
|
||||
'brand' => '品牌A',
|
||||
'specification' => '規格B',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$warehouse = \App\Modules\Inventory\Models\Warehouse::create(['name' => '詳盡倉庫', 'code' => 'DETAIL-WH']);
|
||||
|
||||
// 2. 增加庫存
|
||||
\App\Modules\Inventory\Models\Inventory::create([
|
||||
'product_id' => $product->id,
|
||||
'warehouse_id' => $warehouse->id,
|
||||
'quantity' => 10.5,
|
||||
'batch_number' => 'BATCH-DETAIL-01',
|
||||
'arrival_date' => now(),
|
||||
'expiry_date' => now()->addYear(),
|
||||
'origin_country' => 'TW',
|
||||
]);
|
||||
|
||||
tenancy()->end();
|
||||
|
||||
\Laravel\Sanctum\Sanctum::actingAs($this->user, ['*']);
|
||||
|
||||
// 3. 查詢該倉庫庫存
|
||||
$response = $this->withHeaders([
|
||||
'X-Tenant-Domain' => $this->domain,
|
||||
'Accept' => 'application/json',
|
||||
])->getJson('/api/v1/integration/inventory/DETAIL-WH');
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonStructure([
|
||||
'status',
|
||||
'warehouse_code',
|
||||
'data' => [
|
||||
'*' => [
|
||||
'product_id',
|
||||
'external_pos_id',
|
||||
'product_code',
|
||||
'product_name',
|
||||
'barcode',
|
||||
'category_name',
|
||||
'unit_name',
|
||||
'price',
|
||||
'brand',
|
||||
'specification',
|
||||
'batch_number',
|
||||
'expiry_date',
|
||||
'quantity'
|
||||
]
|
||||
]
|
||||
]);
|
||||
|
||||
$data = $response->json('data.0');
|
||||
$this->assertEquals($product->id, $data['product_id']);
|
||||
$this->assertEquals('DETAIL-001', $data['external_pos_id']);
|
||||
$this->assertEquals('DET-001', $data['product_code']);
|
||||
$this->assertEquals('1234567890123', $data['barcode']);
|
||||
$this->assertEquals('測試大類', $data['category_name']);
|
||||
$this->assertEquals('測試單位', $data['unit_name']);
|
||||
$this->assertEquals(123.45, (float)$data['price']);
|
||||
$this->assertEquals('品牌A', $data['brand']);
|
||||
$this->assertEquals('規格B', $data['specification']);
|
||||
$this->assertEquals('BATCH-DETAIL-01', $data['batch_number']);
|
||||
$this->assertNotEmpty($data['expiry_date']);
|
||||
$this->assertEquals(10.5, $data['quantity']);
|
||||
}
|
||||
|
||||
public function test_order_creation_with_insufficient_stock_creates_negative_no_batch()
|
||||
{
|
||||
tenancy()->initialize($this->tenant);
|
||||
$product = Product::where('code', 'P-001')->first();
|
||||
$warehouse = \App\Modules\Inventory\Models\Warehouse::where('code', 'MAIN')->first();
|
||||
|
||||
// 清空該商品的現有庫存以利測試負數
|
||||
\App\Modules\Inventory\Models\Inventory::where('product_id', $product->id)->delete();
|
||||
|
||||
tenancy()->end();
|
||||
|
||||
\Laravel\Sanctum\Sanctum::actingAs($this->user, ['*']);
|
||||
|
||||
$payload = [
|
||||
'external_order_id' => 'ORD-NEGATIVE-TEST',
|
||||
'warehouse_code' => 'MAIN',
|
||||
'items' => [
|
||||
[
|
||||
'product_id' => $product->id,
|
||||
'batch_number' => 'ANY-BATCH-NAME',
|
||||
'qty' => 10,
|
||||
'price' => 100
|
||||
]
|
||||
]
|
||||
];
|
||||
|
||||
$this->withHeaders(['X-Tenant-Domain' => $this->domain])
|
||||
->postJson('/api/v1/integration/orders', $payload)
|
||||
->assertStatus(201);
|
||||
|
||||
tenancy()->initialize($this->tenant);
|
||||
|
||||
// 驗證應該在 NO-BATCH 產生 -10 的庫存
|
||||
$this->assertDatabaseHas('inventories', [
|
||||
'product_id' => $product->id,
|
||||
'warehouse_id' => $warehouse->id,
|
||||
'batch_number' => 'NO-BATCH',
|
||||
'quantity' => -10,
|
||||
]);
|
||||
|
||||
tenancy()->end();
|
||||
}
|
||||
}
|
||||
|
||||
194
tests/Feature/Integration/ProductSearchApiTest.php
Normal file
194
tests/Feature/Integration/ProductSearchApiTest.php
Normal file
@@ -0,0 +1,194 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Integration;
|
||||
|
||||
use Tests\TestCase;
|
||||
use App\Modules\Core\Models\Tenant;
|
||||
use App\Modules\Core\Models\User;
|
||||
use App\Modules\Inventory\Models\Product;
|
||||
use App\Modules\Inventory\Models\Category;
|
||||
use App\Modules\Inventory\Models\Unit;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Stancl\Tenancy\Facades\Tenancy;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ProductSearchApiTest extends TestCase
|
||||
{
|
||||
protected $tenant;
|
||||
protected $user;
|
||||
protected $domain;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Artisan::call('migrate:fresh');
|
||||
|
||||
$this->domain = 'search-test-' . Str::random(8) . '.erp.local';
|
||||
$tenantId = 'test_tenant_s_' . Str::random(8);
|
||||
|
||||
tenancy()->central(function () use ($tenantId) {
|
||||
$this->tenant = Tenant::create([
|
||||
'id' => $tenantId,
|
||||
'name' => 'Search Test Tenant',
|
||||
]);
|
||||
$this->tenant->domains()->create(['domain' => $this->domain]);
|
||||
});
|
||||
|
||||
tenancy()->initialize($this->tenant);
|
||||
Artisan::call('tenants:migrate');
|
||||
|
||||
$this->user = User::factory()->create(['name' => 'Search Admin']);
|
||||
|
||||
// 建立測試資料
|
||||
$cat1 = Category::firstOrCreate(['name' => '飲品'], ['code' => 'CAT-DRINK']);
|
||||
$cat2 = Category::firstOrCreate(['name' => '食品'], ['code' => 'CAT-FOOD']);
|
||||
$unit = Unit::firstOrCreate(['name' => '瓶'], ['code' => 'BO']);
|
||||
|
||||
Product::create([
|
||||
'name' => '可口可樂',
|
||||
'code' => 'COKE-001',
|
||||
'barcode' => '4710001',
|
||||
'price' => 25,
|
||||
'category_id' => $cat1->id,
|
||||
'base_unit_id' => $unit->id,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$pepsi = Product::create([
|
||||
'name' => '百事可樂',
|
||||
'code' => 'PEPSI-001',
|
||||
'barcode' => '4710002',
|
||||
'price' => 23,
|
||||
'category_id' => $cat1->id,
|
||||
'base_unit_id' => $unit->id,
|
||||
'is_active' => true,
|
||||
]);
|
||||
DB::table('products')->where('id', $pepsi->id)->update(['updated_at' => now()->subDay()]);
|
||||
|
||||
Product::create([
|
||||
'name' => '漢堡',
|
||||
'code' => 'BURGER-001',
|
||||
'barcode' => '4710003',
|
||||
'price' => 50,
|
||||
'category_id' => $cat2->id,
|
||||
'base_unit_id' => $unit->id,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
Product::create([
|
||||
'name' => '停用商品',
|
||||
'code' => 'INACTIVE-001',
|
||||
'is_active' => false,
|
||||
'category_id' => $cat1->id,
|
||||
'base_unit_id' => $unit->id,
|
||||
]);
|
||||
|
||||
tenancy()->end();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if ($this->tenant) {
|
||||
$this->tenant->delete();
|
||||
}
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function test_can_search_all_active_products()
|
||||
{
|
||||
\Laravel\Sanctum\Sanctum::actingAs($this->user, ['*']);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'X-Tenant-Domain' => $this->domain,
|
||||
'Accept' => 'application/json',
|
||||
])->getJson('/api/v1/integration/products');
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonPath('status', 'success')
|
||||
->assertJsonCount(3, 'data'); // 3 active products
|
||||
}
|
||||
|
||||
public function test_can_filter_by_category()
|
||||
{
|
||||
\Laravel\Sanctum\Sanctum::actingAs($this->user, ['*']);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'X-Tenant-Domain' => $this->domain,
|
||||
'Accept' => 'application/json',
|
||||
])->getJson('/api/v1/integration/products?category=食品');
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonCount(1, 'data')
|
||||
->assertJsonPath('data.0.name', '漢堡');
|
||||
}
|
||||
|
||||
public function test_can_filter_by_updated_after()
|
||||
{
|
||||
\Laravel\Sanctum\Sanctum::actingAs($this->user, ['*']);
|
||||
|
||||
// 搜尋今天更新的商品 (百事可樂是昨天更新的,應該被過濾掉)
|
||||
$timeStr = now()->startOfDay()->format('Y-m-d H:i:s');
|
||||
$response = $this->withHeaders([
|
||||
'X-Tenant-Domain' => $this->domain,
|
||||
'Accept' => 'application/json',
|
||||
])->getJson("/api/v1/integration/products?updated_after={$timeStr}");
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonCount(2, 'data'); // 可口可樂, 漢堡 (百事可樂是昨天)
|
||||
}
|
||||
|
||||
public function test_pagination_works()
|
||||
{
|
||||
\Laravel\Sanctum\Sanctum::actingAs($this->user, ['*']);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'X-Tenant-Domain' => $this->domain,
|
||||
'Accept' => 'application/json',
|
||||
])->getJson('/api/v1/integration/products?per_page=2');
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonCount(2, 'data')
|
||||
->assertJsonPath('meta.per_page', 2)
|
||||
->assertJsonPath('meta.total', 3);
|
||||
}
|
||||
|
||||
public function test_can_filter_by_precision_params()
|
||||
{
|
||||
\Laravel\Sanctum\Sanctum::actingAs($this->user, ['*']);
|
||||
|
||||
// 1. By Barcode
|
||||
$response = $this->withHeaders([
|
||||
'X-Tenant-Domain' => $this->domain,
|
||||
'Accept' => 'application/json',
|
||||
])->getJson('/api/v1/integration/products?barcode=4710001');
|
||||
$response->assertStatus(200)->assertJsonCount(1, 'data')->assertJsonPath('data.0.name', '可口可樂');
|
||||
|
||||
// 2. By Code
|
||||
$response = $this->withHeaders([
|
||||
'X-Tenant-Domain' => $this->domain,
|
||||
'Accept' => 'application/json',
|
||||
])->getJson('/api/v1/integration/products?code=PEPSI-001');
|
||||
$response->assertStatus(200)->assertJsonCount(1, 'data')->assertJsonPath('data.0.name', '百事可樂');
|
||||
|
||||
// 3. By product_id (ERP ID)
|
||||
$productId = Product::where('code', 'BURGER-001')->value('id');
|
||||
$response = $this->withHeaders([
|
||||
'X-Tenant-Domain' => $this->domain,
|
||||
'Accept' => 'application/json',
|
||||
])->getJson("/api/v1/integration/products?product_id={$productId}");
|
||||
$response->assertStatus(200)->assertJsonCount(1, 'data')->assertJsonPath('data.0.name', '漢堡');
|
||||
}
|
||||
|
||||
public function test_is_protected_by_auth()
|
||||
{
|
||||
// 不帶 Token
|
||||
$response = $this->withHeaders([
|
||||
'X-Tenant-Domain' => $this->domain,
|
||||
'Accept' => 'application/json',
|
||||
])->getJson('/api/v1/integration/products');
|
||||
|
||||
$response->assertStatus(401);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user