Files
datahub/backend/test/Cases/Integration/Sku/SkuMappingControllerTest.php
T
2026-04-17 13:57:01 +08:00

733 lines
26 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace HyperfTest\Cases\Integration\Sku;
use App\Model\SkuMapping;
use App\Model\SkuOrigin;
use HyperfTest\TestCase;
use HyperfTest\Traits\AuthenticatedTestTrait;
/**
* SkuMappingController 集成测试
*
* 覆盖列表筛选、origin_sku_id 过滤、创建校验(platform_product_id 可选)、CRUD、认证拦截、
* 三元组唯一性冲突、bundled 字段 CRUD 与筛选、组合商品映射(NC-6)、origin_sku 快照自动填充(NC-7
*
* @internal
* @coversNothing
*/
class SkuMappingControllerTest extends TestCase
{
use AuthenticatedTestTrait;
protected function hasData(): bool
{
return $this->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 字段 CRUDP21.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);
}
}