runInCoroutine(static function (): bool { return SkuMapping::query()->exists(); }); } protected function getFirstRecord(): ?array { return $this->runInCoroutine(static function (): ?array { $record = SkuMapping::query()->first(); return $record ? $record->toArray() : null; }); } protected function getFirstOrigin(): ?array { return $this->runInCoroutine(static function (): ?array { $record = SkuOrigin::query()->first(); return $record ? $record->toArray() : null; }); } 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 { $response = $this->get('/api/v1/sku-mappings', [], $this->authHeaders()); $response->assertStatus(200); $response->assertJsonPath('code', 0); $response->assertJsonStructure([ 'code', 'message', 'data' => [ 'items', 'total', 'page', 'per_page', ], ]); } public function test_list_filter_by_origin_sku_id(): void { if (!$this->hasData()) { $this->markTestSkipped('没有 SKU Mapping 数据'); } $first = $this->getFirstRecord(); if (!$first['origin_sku_id']) { $this->markTestSkipped('首条记录无 origin_sku_id'); } $response = $this->get('/api/v1/sku-mappings', [ 'origin_sku_id' => $first['origin_sku_id'], ], $this->authHeaders()); $response->assertStatus(200); $body = json_decode($response->getContent(), true); foreach ($body['data']['items'] as $item) { $this->assertSame($first['origin_sku_id'], $item['origin_sku_id']); } } public function test_list_filter_by_company_and_platform(): void { if (!$this->hasData()) { $this->markTestSkipped('没有 SKU Mapping 数据'); } $first = $this->getFirstRecord(); $response = $this->get('/api/v1/sku-mappings', [ 'company_id' => $first['company_id'], 'platform_id' => $first['platform_id'], ], $this->authHeaders()); $response->assertStatus(200); $body = json_decode($response->getContent(), true); foreach ($body['data']['items'] as $item) { $this->assertSame($first['company_id'], $item['company_id']); $this->assertSame($first['platform_id'], $item['platform_id']); } } public function test_list_filter_by_enabled(): void { $response = $this->get('/api/v1/sku-mappings', ['enabled' => 'true'], $this->authHeaders()); $response->assertStatus(200); $body = json_decode($response->getContent(), true); foreach ($body['data']['items'] as $item) { $this->assertTrue($item['enabled']); } } // ========== 创建校验 ========== public function test_create_without_platform_product_id_succeeds(): 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' => $origin['sku'], 'origin_sku_id' => $origin['id'], 'platform_outer_sku' => 'TEST-' . uniqid(), ], $this->authHeaders()); $response->assertStatus(201); $response->assertJsonPath('code', 0); // 清理测试数据 $body = json_decode($response->getContent(), true); if (isset($body['data']['id'])) { $this->runInCoroutine(static function () use ($body): void { SkuMapping::query()->where('id', $body['data']['id'])->delete(); }); } } public function test_create_with_platform_product_id_succeeds(): 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' => $origin['sku'], 'origin_sku_id' => $origin['id'], 'platform_product_id' => 'PROD-TEST-' . uniqid(), 'platform_outer_sku' => 'TEST-' . uniqid(), ], $this->authHeaders()); $response->assertStatus(201); $response->assertJsonPath('code', 0); // 清理测试数据 $body = json_decode($response->getContent(), true); if (isset($body['data']['id'])) { $this->runInCoroutine(static function () use ($body): void { SkuMapping::query()->where('id', $body['data']['id'])->delete(); }); } } public function test_create_requires_company_id(): void { $response = $this->post('/api/v1/sku-mappings', [ 'platform_id' => 1, 'origin_sku_id' => 1, 'platform_outer_sku' => 'TEST-SKU', ], $this->authHeaders()); $response->assertStatus(422); $response->assertJsonPath('message', '缺少必填字段: company_id'); } public function test_create_requires_platform_id(): void { $response = $this->post('/api/v1/sku-mappings', [ 'company_id' => 1, 'origin_sku_id' => 1, 'platform_outer_sku' => 'TEST-SKU', ], $this->authHeaders()); $response->assertStatus(422); $response->assertJsonPath('message', '缺少必填字段: platform_id'); } public function test_create_requires_origin_sku_id(): void { $response = $this->post('/api/v1/sku-mappings', [ 'company_id' => 1, 'platform_id' => 1, 'platform_outer_sku' => 'TEST-SKU', ], $this->authHeaders()); $response->assertStatus(422); $response->assertJsonPath('message', '缺少必填字段: origin_sku_id'); } public function test_create_requires_platform_outer_sku(): void { $response = $this->post('/api/v1/sku-mappings', [ 'company_id' => 1, 'platform_id' => 1, 'origin_sku_id' => 1, ], $this->authHeaders()); $response->assertStatus(422); $response->assertJsonPath('message', '缺少必填字段: platform_outer_sku'); } // ========== 详情接口 ========== public function test_detail_not_found_returns_404(): void { $response = $this->get('/api/v1/sku-mappings/999999999', [], $this->authHeaders()); $response->assertStatus(404); $response->assertJsonPath('code', 404); } // ========== 更新 & 删除 ========== public function test_update_mapping(): void { if (!$this->hasData()) { $this->markTestSkipped('没有 SKU Mapping 数据'); } $first = $this->getFirstRecord(); $response = $this->put('/api/v1/sku-mappings/' . $first['id'], [ 'note' => 'integration-test-update-' . uniqid(), ], $this->authHeaders()); $response->assertStatus(200); $response->assertJsonPath('code', 0); } public function test_delete_mapping(): void { // 创建一条临时记录然后删除 $origin = $this->getFirstOrigin(); if (!$origin) { $this->markTestSkipped('没有 SKU Origin 数据'); } $createResponse = $this->post('/api/v1/sku-mappings', [ 'company_id' => $origin['company_id'], 'platform_id' => 1, 'origin_sku' => $origin['sku'], 'origin_sku_id' => $origin['id'], 'platform_outer_sku' => 'DELETE-TEST-' . uniqid(), ], $this->authHeaders()); $createResponse->assertStatus(201); $body = json_decode($createResponse->getContent(), true); $newId = $body['data']['id']; $deleteResponse = $this->delete('/api/v1/sku-mappings/' . $newId, [], $this->authHeaders()); $deleteResponse->assertStatus(200); $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 { $response = $this->get('/api/v1/sku-mappings'); $response->assertStatus(401); } }