diff --git a/backend/test/Cases/Integration/Sku/SkuMappingControllerTest.php b/backend/test/Cases/Integration/Sku/SkuMappingControllerTest.php index a26c800..3498a75 100644 --- a/backend/test/Cases/Integration/Sku/SkuMappingControllerTest.php +++ b/backend/test/Cases/Integration/Sku/SkuMappingControllerTest.php @@ -12,7 +12,8 @@ use HyperfTest\Traits\AuthenticatedTestTrait; /** * SkuMappingController 集成测试 * - * 覆盖列表筛选、origin_sku_id 过滤、创建校验(platform_product_id 可选)、CRUD、认证拦截 + * 覆盖列表筛选、origin_sku_id 过滤、创建校验(platform_product_id 可选)、CRUD、认证拦截、 + * 三元组唯一性冲突、bundled 字段 CRUD 与筛选、组合商品映射(NC-6)、origin_sku 快照自动填充(NC-7) * * @internal * @coversNothing @@ -44,6 +45,31 @@ class SkuMappingControllerTest extends TestCase }); } + protected function createTestOrigin(int $company_id): array + { + return $this->runInCoroutine(static function () use ($company_id): array { + // bin2hex(random_bytes) 比 uniqid() 子串更可靠,避免 barcode 微秒碰撞 + $suffix = bin2hex(random_bytes(8)); + $origin = SkuOrigin::query()->create([ + 'company_id' => $company_id, + 'sku' => 'TESTSKU-' . $suffix, + 'barcode' => 'BC-' . $suffix, + 'name' => 'P21.3 Test Origin ' . $suffix, + ]); + return $origin->toArray(); + }); + } + + protected function deleteTestOrigins(array $ids): void + { + if (empty($ids)) { + return; + } + $this->runInCoroutine(static function () use ($ids): void { + SkuOrigin::query()->whereIn('id', $ids)->delete(); + }); + } + // ========== 列表接口 ========== public function test_list_returns_paginated_data(): void @@ -187,6 +213,7 @@ class SkuMappingControllerTest extends TestCase ], $this->authHeaders()); $response->assertStatus(422); + $response->assertJsonPath('message', '缺少必填字段: company_id'); } public function test_create_requires_platform_id(): void @@ -198,6 +225,7 @@ class SkuMappingControllerTest extends TestCase ], $this->authHeaders()); $response->assertStatus(422); + $response->assertJsonPath('message', '缺少必填字段: platform_id'); } public function test_create_requires_origin_sku_id(): void @@ -209,6 +237,7 @@ class SkuMappingControllerTest extends TestCase ], $this->authHeaders()); $response->assertStatus(422); + $response->assertJsonPath('message', '缺少必填字段: origin_sku_id'); } public function test_create_requires_platform_outer_sku(): void @@ -220,6 +249,7 @@ class SkuMappingControllerTest extends TestCase ], $this->authHeaders()); $response->assertStatus(422); + $response->assertJsonPath('message', '缺少必填字段: platform_outer_sku'); } // ========== 详情接口 ========== @@ -276,6 +306,421 @@ class SkuMappingControllerTest extends TestCase $deleteResponse->assertJsonPath('code', 0); } + // ========== 三元组唯一性冲突(P21.3) ========== + + public function test_store_duplicate_mapping_returns_422(): void + { + $origin = $this->getFirstOrigin(); + if (!$origin) { + $this->markTestSkipped('没有 SKU Origin 数据'); + } + + $platform_outer_sku = 'DUP-TEST-' . uniqid(); + $created_id = null; + + try { + $first_response = $this->post('/api/v1/sku-mappings', [ + 'company_id' => $origin['company_id'], + 'platform_id' => 1, + 'origin_sku_id' => $origin['id'], + 'platform_outer_sku' => $platform_outer_sku, + ], $this->authHeaders()); + + $first_response->assertStatus(201); + $first_body = json_decode($first_response->getContent(), true); + $created_id = $first_body['data']['id']; + + $dup_response = $this->post('/api/v1/sku-mappings', [ + 'company_id' => $origin['company_id'], + 'platform_id' => 1, + 'origin_sku_id' => $origin['id'], + 'platform_outer_sku' => $platform_outer_sku, + ], $this->authHeaders()); + + $dup_response->assertStatus(422); + $dup_response->assertJsonPath( + 'message', + '该映射组合已存在(origin_sku_id + platform_id + platform_outer_sku)', + ); + } finally { + if ($created_id !== null) { + $this->runInCoroutine(static function () use ($created_id): void { + SkuMapping::query()->where('id', $created_id)->delete(); + }); + } + } + } + + public function test_update_to_duplicate_mapping_returns_422(): void + { + $origin = $this->getFirstOrigin(); + if (!$origin) { + $this->markTestSkipped('没有 SKU Origin 数据'); + } + + $suffix = uniqid(); + $a_sku = 'AAA-' . $suffix; + $b_sku = 'BBB-' . $suffix; + $a_id = null; + $b_id = null; + + try { + $a_response = $this->post('/api/v1/sku-mappings', [ + 'company_id' => $origin['company_id'], + 'platform_id' => 1, + 'origin_sku_id' => $origin['id'], + 'platform_outer_sku' => $a_sku, + ], $this->authHeaders()); + $a_response->assertStatus(201); + $a_id = json_decode($a_response->getContent(), true)['data']['id']; + + $b_response = $this->post('/api/v1/sku-mappings', [ + 'company_id' => $origin['company_id'], + 'platform_id' => 1, + 'origin_sku_id' => $origin['id'], + 'platform_outer_sku' => $b_sku, + ], $this->authHeaders()); + $b_response->assertStatus(201); + $b_id = json_decode($b_response->getContent(), true)['data']['id']; + + // 把 B 改成 A 的 platform_outer_sku 触发三元组冲突 + $update_response = $this->put('/api/v1/sku-mappings/' . $b_id, [ + 'platform_outer_sku' => $a_sku, + ], $this->authHeaders()); + + $update_response->assertStatus(422); + $update_response->assertJsonPath( + 'message', + '该映射组合已存在(origin_sku_id + platform_id + platform_outer_sku)', + ); + } finally { + $this->runInCoroutine(static function () use ($a_id, $b_id): void { + if ($a_id !== null) { + SkuMapping::query()->where('id', $a_id)->delete(); + } + if ($b_id !== null) { + SkuMapping::query()->where('id', $b_id)->delete(); + } + }); + } + } + + // ========== bundled 字段 CRUD(P21.3) ========== + + public function test_store_with_bundled_true(): void + { + $origin = $this->getFirstOrigin(); + if (!$origin) { + $this->markTestSkipped('没有 SKU Origin 数据'); + } + + $response = $this->post('/api/v1/sku-mappings', [ + 'company_id' => $origin['company_id'], + 'platform_id' => 1, + 'origin_sku_id' => $origin['id'], + 'platform_outer_sku' => 'BUNDLED-T-' . uniqid(), + 'bundled' => true, + ], $this->authHeaders()); + + $response->assertStatus(201); + $response->assertJsonPath('data.bundled', true); + + $body = json_decode($response->getContent(), true); + $new_id = $body['data']['id']; + $this->runInCoroutine(static function () use ($new_id): void { + SkuMapping::query()->where('id', $new_id)->delete(); + }); + } + + public function test_store_defaults_bundled_false(): void + { + $origin = $this->getFirstOrigin(); + if (!$origin) { + $this->markTestSkipped('没有 SKU Origin 数据'); + } + + $response = $this->post('/api/v1/sku-mappings', [ + 'company_id' => $origin['company_id'], + 'platform_id' => 1, + 'origin_sku_id' => $origin['id'], + 'platform_outer_sku' => 'BUNDLED-F-' . uniqid(), + ], $this->authHeaders()); + + $response->assertStatus(201); + $response->assertJsonPath('data.bundled', false); + + $body = json_decode($response->getContent(), true); + $new_id = $body['data']['id']; + $this->runInCoroutine(static function () use ($new_id): void { + SkuMapping::query()->where('id', $new_id)->delete(); + }); + } + + public function test_update_bundled_field(): void + { + $origin = $this->getFirstOrigin(); + if (!$origin) { + $this->markTestSkipped('没有 SKU Origin 数据'); + } + + $created_id = null; + try { + $create_response = $this->post('/api/v1/sku-mappings', [ + 'company_id' => $origin['company_id'], + 'platform_id' => 1, + 'origin_sku_id' => $origin['id'], + 'platform_outer_sku' => 'BUNDLED-UP-' . uniqid(), + ], $this->authHeaders()); + $create_response->assertStatus(201); + $created_id = json_decode($create_response->getContent(), true)['data']['id']; + + $update_response = $this->put('/api/v1/sku-mappings/' . $created_id, [ + 'bundled' => true, + ], $this->authHeaders()); + $update_response->assertStatus(200); + + $detail_response = $this->get('/api/v1/sku-mappings/' . $created_id, [], $this->authHeaders()); + $detail_response->assertStatus(200); + $detail_response->assertJsonPath('data.bundled', true); + } finally { + if ($created_id !== null) { + $this->runInCoroutine(static function () use ($created_id): void { + SkuMapping::query()->where('id', $created_id)->delete(); + }); + } + } + } + + public function test_index_filter_by_bundled(): void + { + // 自建 bundled=true / false 两条记录,避免依赖既有数据量导致空 items 静默通过 + $origin = $this->getFirstOrigin(); + if (!$origin) { + $this->markTestSkipped('没有 SKU Origin 数据'); + } + + $suffix = uniqid(); + $true_id = null; + $false_id = null; + try { + $true_response = $this->post('/api/v1/sku-mappings', [ + 'company_id' => $origin['company_id'], + 'platform_id' => 1, + 'origin_sku_id' => $origin['id'], + 'platform_outer_sku' => 'FILTER-T-' . $suffix, + 'bundled' => true, + ], $this->authHeaders()); + $true_response->assertStatus(201); + $true_id = json_decode($true_response->getContent(), true)['data']['id']; + + $false_response = $this->post('/api/v1/sku-mappings', [ + 'company_id' => $origin['company_id'], + 'platform_id' => 1, + 'origin_sku_id' => $origin['id'], + 'platform_outer_sku' => 'FILTER-F-' . $suffix, + 'bundled' => false, + ], $this->authHeaders()); + $false_response->assertStatus(201); + $false_id = json_decode($false_response->getContent(), true)['data']['id']; + + $list_true = $this->get('/api/v1/sku-mappings', ['bundled' => 'true', 'per_page' => 100], $this->authHeaders()); + $list_true->assertStatus(200); + $body_true = json_decode($list_true->getContent(), true); + $this->assertNotEmpty($body_true['data']['items'], 'bundled=true 筛选应至少命中新建的种子记录'); + foreach ($body_true['data']['items'] as $item) { + $this->assertTrue($item['bundled'], "bundled=true 筛选却返回 bundled=false 的记录 id={$item['id']}"); + } + + $list_false = $this->get('/api/v1/sku-mappings', ['bundled' => 'false', 'per_page' => 100], $this->authHeaders()); + $list_false->assertStatus(200); + $body_false = json_decode($list_false->getContent(), true); + $this->assertNotEmpty($body_false['data']['items'], 'bundled=false 筛选应至少命中新建的种子记录'); + foreach ($body_false['data']['items'] as $item) { + $this->assertFalse($item['bundled'], "bundled=false 筛选却返回 bundled=true 的记录 id={$item['id']}"); + } + } finally { + $this->runInCoroutine(static function () use ($true_id, $false_id): void { + foreach ([$true_id, $false_id] as $id) { + if ($id !== null) { + SkuMapping::query()->where('id', $id)->delete(); + } + } + }); + } + } + + // ========== 组合商品场景(P21.3, NC-6) ========== + + public function test_bundle_multiple_origin_skus_to_same_platform_outer_sku(): void + { + $base_origin = $this->getFirstOrigin(); + if (!$base_origin) { + $this->markTestSkipped('没有 SKU Origin 基线数据'); + } + + $company_id = (int) $base_origin['company_id']; + $extra_origin_ids = []; + $created_mapping_ids = []; + + try { + // createTestOrigin 的任一次调用异常也能被 finally 清理已创建的 origin + $extra_1 = $this->createTestOrigin($company_id); + $extra_origin_ids[] = $extra_1['id']; + $extra_2 = $this->createTestOrigin($company_id); + $extra_origin_ids[] = $extra_2['id']; + + $origins = [$base_origin, $extra_1, $extra_2]; + $platform_outer_sku = 'BUNDLE-SHARED-' . uniqid(); + + foreach ($origins as $origin) { + $response = $this->post('/api/v1/sku-mappings', [ + 'company_id' => $origin['company_id'], + 'platform_id' => 1, + 'origin_sku_id' => $origin['id'], + 'platform_outer_sku' => $platform_outer_sku, + 'bundled' => true, + ], $this->authHeaders()); + + $response->assertStatus(201); + $body = json_decode($response->getContent(), true); + $this->assertSame($platform_outer_sku, $body['data']['platform_outer_sku']); + $this->assertTrue($body['data']['bundled']); + $created_mapping_ids[] = $body['data']['id']; + } + + $this->assertCount(3, array_unique($created_mapping_ids)); + } finally { + if (!empty($created_mapping_ids)) { + $this->runInCoroutine(static function () use ($created_mapping_ids): void { + SkuMapping::query()->whereIn('id', $created_mapping_ids)->delete(); + }); + } + $this->deleteTestOrigins($extra_origin_ids); + } + } + + public function test_bundle_mapping_unique_constraint_per_origin_sku(): void + { + $origin = $this->getFirstOrigin(); + if (!$origin) { + $this->markTestSkipped('没有 SKU Origin 数据'); + } + + $platform_outer_sku = 'BUNDLE-DUP-' . uniqid(); + $created_id = null; + + try { + $first_response = $this->post('/api/v1/sku-mappings', [ + 'company_id' => $origin['company_id'], + 'platform_id' => 1, + 'origin_sku_id' => $origin['id'], + 'platform_outer_sku' => $platform_outer_sku, + 'bundled' => true, + ], $this->authHeaders()); + $first_response->assertStatus(201); + $first_body = json_decode($first_response->getContent(), true); + $created_id = $first_body['data']['id']; + // 额外断言首次 bundled=true 被正确持久化,与 test_store_duplicate_mapping 区分 + $this->assertTrue($first_body['data']['bundled']); + + // bundled=true 不应绕过三元组唯一索引 + $dup_response = $this->post('/api/v1/sku-mappings', [ + 'company_id' => $origin['company_id'], + 'platform_id' => 1, + 'origin_sku_id' => $origin['id'], + 'platform_outer_sku' => $platform_outer_sku, + 'bundled' => true, + ], $this->authHeaders()); + + $dup_response->assertStatus(422); + $dup_response->assertJsonPath( + 'message', + '该映射组合已存在(origin_sku_id + platform_id + platform_outer_sku)', + ); + } finally { + if ($created_id !== null) { + $this->runInCoroutine(static function () use ($created_id): void { + SkuMapping::query()->where('id', $created_id)->delete(); + }); + } + } + } + + // ========== origin_sku 快照自动填充(P21.3, NC-7) ========== + + public function test_store_auto_fills_origin_sku_from_sku_origin(): void + { + $origin = $this->getFirstOrigin(); + if (!$origin) { + $this->markTestSkipped('没有 SKU Origin 数据'); + } + + $created_id = null; + try { + // 客户端传入的 origin_sku 必须被服务端的快照覆盖,而非尊重请求体值 + $response = $this->post('/api/v1/sku-mappings', [ + 'company_id' => $origin['company_id'], + 'platform_id' => 1, + 'origin_sku_id' => $origin['id'], + 'origin_sku' => 'CLIENT-SHOULD-NOT-WIN-' . uniqid(), + 'platform_outer_sku' => 'SNAPSHOT-' . uniqid(), + ], $this->authHeaders()); + + $response->assertStatus(201); + $response->assertJsonPath('data.origin_sku', $origin['sku']); + + $created_id = json_decode($response->getContent(), true)['data']['id']; + } finally { + if ($created_id !== null) { + $this->runInCoroutine(static function () use ($created_id): void { + SkuMapping::query()->where('id', $created_id)->delete(); + }); + } + } + } + + public function test_update_origin_sku_id_refreshes_origin_sku_snapshot(): void + { + $origin_a = $this->getFirstOrigin(); + if (!$origin_a) { + $this->markTestSkipped('没有 SKU Origin 基线数据'); + } + + $origin_b = null; + $mapping_id = null; + try { + $origin_b = $this->createTestOrigin((int) $origin_a['company_id']); + + $create_response = $this->post('/api/v1/sku-mappings', [ + 'company_id' => $origin_a['company_id'], + 'platform_id' => 1, + 'origin_sku_id' => $origin_a['id'], + 'platform_outer_sku' => 'SNAPSHOT-UP-' . uniqid(), + ], $this->authHeaders()); + $create_response->assertStatus(201); + $create_body = json_decode($create_response->getContent(), true); + $mapping_id = $create_body['data']['id']; + $this->assertSame($origin_a['sku'], $create_body['data']['origin_sku']); + + $update_response = $this->put('/api/v1/sku-mappings/' . $mapping_id, [ + 'origin_sku_id' => $origin_b['id'], + ], $this->authHeaders()); + + $update_response->assertStatus(200); + $update_response->assertJsonPath('data.origin_sku', $origin_b['sku']); + $update_response->assertJsonPath('data.origin_sku_id', $origin_b['id']); + } finally { + if ($mapping_id !== null) { + $this->runInCoroutine(static function () use ($mapping_id): void { + SkuMapping::query()->where('id', $mapping_id)->delete(); + }); + } + if ($origin_b !== null) { + $this->deleteTestOrigins([$origin_b['id']]); + } + } + } + // ========== 认证拦截 ========== public function test_list_without_token_returns_401(): void